package sqlite

import (
	"database/sql"
	"encoding/json"
	"errors"
	"fmt"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/zk-org/zk/internal/core"
	"github.com/zk-org/zk/internal/util"
	"github.com/zk-org/zk/internal/util/fts5"
	"github.com/zk-org/zk/internal/util/paths"
	strutil "github.com/zk-org/zk/internal/util/strings"
)

// NoteDAO persists notes in the SQLite database.
type NoteDAO struct {
	tx        Transaction
	logger    util.Logger
	extension string

	// Prepared SQL statements
	indexedStmt               *LazyStmt
	addStmt                   *LazyStmt
	updateStmt                *LazyStmt
	removeStmt                *LazyStmt
	findIDByPathStmt          *LazyStmt
	findIDsByFilenameLikeStmt *LazyStmt
	findIDsByPathLikeStmt     *LazyStmt
	findIDsByPathPrefixStmt   *LazyStmt
	findByIDStmt              *LazyStmt
}

// NewNoteDAO creates a new instance of a DAO working on the given database
// transaction.
func NewNoteDAO(tx Transaction, logger util.Logger, extension string) *NoteDAO {
	return &NoteDAO{
		tx:        tx,
		logger:    logger,
		extension: extension,

		// Get file info about all indexed notes.
		indexedStmt: tx.PrepareLazy(`
			SELECT path, modified from notes
			 ORDER BY sortable_path ASC
		`),

		// Add a new note to the index.
		addStmt: tx.PrepareLazy(`
			INSERT INTO notes (path, sortable_path, filename, title, lead, body, raw_content, word_count, metadata, checksum, created, modified)
			VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
		`),

		// Update the content of a note.
		updateStmt: tx.PrepareLazy(`
			UPDATE notes
			   SET title = ?, lead = ?, body = ?, raw_content = ?, word_count = ?, metadata = ?, checksum = ?, modified = ?
			 WHERE path = ?
		`),

		// Remove a note.
		removeStmt: tx.PrepareLazy(`
			DELETE FROM notes
			 WHERE id = ?
		`),

		// Find a note ID from its exact path.
		findIDByPathStmt: tx.PrepareLazy(`
			SELECT id FROM notes
			 WHERE path = ?
		`),

		// Find note IDs by filename LIKE pattern.
		findIDsByFilenameLikeStmt: tx.PrepareLazy(`
			SELECT id FROM notes
			 WHERE filename LIKE ? ESCAPE '\'
			 ORDER BY LENGTH(path) ASC
		`),

		// Find note IDs by path LIKE pattern.
		findIDsByPathLikeStmt: tx.PrepareLazy(`
			SELECT id FROM notes
			 WHERE path LIKE ? ESCAPE '\'
			 ORDER BY LENGTH(path) ASC
		`),

		// Find note IDs where href is a complete leading path component.
		findIDsByPathPrefixStmt: tx.PrepareLazy(`
			SELECT id FROM notes
			 WHERE (path LIKE ? ESCAPE '\' AND path NOT LIKE ? ESCAPE '\')
			    OR path LIKE ? ESCAPE '\'
			 ORDER BY LENGTH(path) ASC
		`),

		// Find a note from its ID.
		findByIDStmt: tx.PrepareLazy(`
			SELECT id, path, title, lead, body, raw_content, word_count, created, modified, metadata, checksum, tags, lead AS snippet
			  FROM notes_with_metadata
			 WHERE id = ?
		`),
	}
}

// Indexed returns file info of all indexed notes.
func (d *NoteDAO) Indexed() (<-chan paths.Metadata, error) {
	rows, err := d.indexedStmt.Query()
	if err != nil {
		return nil, err
	}

	c := make(chan paths.Metadata)
	go func() {
		defer close(c)
		defer rows.Close()
		var (
			path     string
			modified time.Time
		)

		for rows.Next() {
			err := rows.Scan(&path, &modified)
			if err != nil {
				d.logger.Err(err)
			}

			c <- paths.Metadata{
				Path:     path,
				Modified: modified,
			}
		}

		err = rows.Err()
		if err != nil {
			d.logger.Err(err)
		}
	}()

	return c, nil
}

// Add inserts a new note to the index.
func (d *NoteDAO) Add(note core.Note) (core.NoteID, error) {
	// For sortable_path, we replace in path / by the shortest non printable
	// character available to make it sortable. Without this, sorting by the
	// path would be a lexicographical sort instead of being the same order
	// returned by filepath.Walk.
	// \x01 is used instead of \x00, because SQLite treats \x00 as and end of
	// string.
	sortablePath := strings.ReplaceAll(note.Path, "/", "\x01")
	filename := filepath.Base(note.Path)

	metadata := d.metadataToJSON(note)
	res, err := d.addStmt.Exec(
		note.Path, sortablePath, filename, note.Title, note.Lead, note.Body,
		note.RawContent, note.WordCount, metadata, note.Checksum, note.Created,
		note.Modified,
	)
	if err != nil {
		return 0, err
	}

	lastID, err := res.LastInsertId()
	if err != nil {
		return 0, err
	}

	return core.NoteID(lastID), err
}

// Update modifies an existing note.
func (d *NoteDAO) Update(note core.Note) (core.NoteID, error) {
	id, err := d.FindIDByPath(note.Path)
	if err != nil {
		return 0, err
	}
	if !id.IsValid() {
		return 0, errors.New("note not found in the index")
	}

	metadata := d.metadataToJSON(note)
	_, err = d.updateStmt.Exec(
		note.Title, note.Lead, note.Body, note.RawContent, note.WordCount,
		metadata, note.Checksum, note.Modified, note.Path,
	)
	return id, err
}

func (d *NoteDAO) metadataToJSON(note core.Note) string {
	json, err := json.Marshal(note.Metadata)
	if err != nil {
		// Failure to serialize the metadata to JSON should not prevent the
		// note from being saved.
		d.logger.Err(fmt.Errorf("cannot serialize note metadata to JSON: %s: %w", note.Path, err))
		return "{}"
	}
	return string(json)
}

// Remove deletes the note with the given path from the index.
func (d *NoteDAO) Remove(path string) error {
	id, err := d.FindIDByPath(path)
	if err != nil {
		return err
	}
	if !id.IsValid() {
		return errors.New("note not found in the index")
	}

	_, err = d.removeStmt.Exec(id)
	return err
}

func (d *NoteDAO) FindIDByPath(path string) (core.NoteID, error) {
	row, err := d.findIDByPathStmt.QueryRow(path)
	if err != nil {
		return core.NoteID(0), err
	}
	return idForRow(row)
}

func idForRow(row *sql.Row) (core.NoteID, error) {
	var id sql.NullInt64
	err := row.Scan(&id)

	switch {
	case err == sql.ErrNoRows:
		return 0, nil
	case err != nil:
		return 0, err
	default:
		return core.NoteID(id.Int64), nil
	}
}

func (d *NoteDAO) findIDsWithStmt(stmt *LazyStmt, args ...any) ([]core.NoteID, error) {
	ids := []core.NoteID{}
	rows, err := stmt.Query(args...)
	if err != nil {
		return ids, err
	}
	defer rows.Close()

	for rows.Next() {
		var id sql.NullInt64
		err := rows.Scan(&id)
		if err != nil {
			return ids, err
		}

		ids = append(ids, core.NoteID(id.Int64))
	}

	return ids, nil
}

func (d *NoteDAO) FindIDByHref(href string, allowPartialHref bool) (core.NoteID, error) {
	ids, err := d.FindIdsByHref(href, allowPartialHref)
	if len(ids) == 0 || err != nil {
		return 0, err
	}
	return ids[0], nil
}

// findIDsByHrefs returns the ID of notes with the given href.
// The href is treated as relative to the notebook root.
func (d *NoteDAO) findIDsByHrefs(hrefs []string, allowPartialHrefs bool) ([]core.NoteID, error) {
	ids := make([]core.NoteID, 0)
	for _, href := range hrefs {
		cids, err := d.FindIdsByHref(href, allowPartialHrefs)
		if err != nil {
			return ids, err
		}
		ids = append(ids, cids...)
	}
	return ids, nil
}

// FindIdsByHref finds note IDs which match the given href string.
// This implements logic similar to NoteIndex.linkMatchesPath.
func (d *NoteDAO) FindIdsByHref(href string, allowPartialHref bool) ([]core.NoteID, error) {
	// Remove any anchor at the end of the HREF, since it's most likely
	// matching a sub-section in the note.
	href = strings.SplitN(href, "#", 2)[0]

	href = strings.NewReplacer("%", "\\%", "_", "\\_").Replace(href)

	// Prioritise exact match with extension.
	id, err := d.FindIDByPath(href + "." + d.extension)
	if err != nil {
		return nil, err
	}
	if id.IsValid() {
		return []core.NoteID{id}, nil
	}

	var ids []core.NoteID
	if allowPartialHref {
		// Filename (not path) contains 'href' anywhere.
		ids, err = d.findIDsWithStmt(d.findIDsByFilenameLikeStmt, "%"+href+"%")
		if len(ids) > 0 || err != nil {
			return ids, err
		}

		// Path contains 'href' anywhere.
		ids, err = d.findIDsWithStmt(d.findIDsByPathLikeStmt, "%"+href+"%")
		if len(ids) > 0 || err != nil {
			return ids, err
		}
	}

	// Path either:
	// 1. starts with 'href' and has no slash after href.
	// 2. starts with 'href/', followed by more content.
	// Three params:
	// 1. href% for prefix,
	// 2. href%/% to exclude slashes after href,
	// 3. href/% for directory
	ids, err = d.findIDsWithStmt(d.findIDsByPathPrefixStmt, href+"%", href+"%/%", href+"/%")
	if len(ids) > 0 || err != nil {
		return ids, err
	}

	return []core.NoteID{}, nil
}

func (d *NoteDAO) FindMinimal(opts core.NoteFindOpts) ([]core.MinimalNote, error) {
	notes := make([]core.MinimalNote, 0)

	opts, err := d.expandMentionsIntoMatch(opts)
	if err != nil {
		return notes, err
	}

	rows, err := d.findRows(opts, noteSelectionMinimal)
	if err != nil {
		return notes, err
	}
	defer rows.Close()

	for rows.Next() {
		note, err := d.scanMinimalNote(rows)
		if err != nil {
			d.logger.Err(err)
			continue
		}
		if note != nil {
			notes = append(notes, *note)
		}
	}

	return notes, nil
}

// Find returns all the notes matching the given criteria.
func (d *NoteDAO) Find(opts core.NoteFindOpts) ([]core.ContextualNote, error) {
	notes := make([]core.ContextualNote, 0)

	opts, err := d.expandMentionsIntoMatch(opts)
	if err != nil {
		return notes, err
	}

	rows, err := d.findRows(opts, noteSelectionFull)
	if err != nil {
		return notes, err
	}
	defer rows.Close()

	for rows.Next() {
		note, err := d.scanNote(rows)
		if err != nil {
			d.logger.Err(err)
			continue
		}
		if note != nil {
			notes = append(notes, *note)
		}
	}

	return notes, nil
}

// parseListFromNullString splits a 0-separated string.
func parseListFromNullString(str sql.NullString) []string {
	list := []string{}
	if str.Valid && str.String != "" {
		list = strings.Split(str.String, "\x01")
		list = strutil.RemoveDuplicates(list)
		list = strutil.RemoveBlank(list)
	}
	return list
}

// expandMentionsIntoMatch finds the titles associated with the notes in opts.Mention to
// expand them into the opts.Match predicate.
func (d *NoteDAO) expandMentionsIntoMatch(opts core.NoteFindOpts) (core.NoteFindOpts, error) {
	if opts.Mention == nil {
		return opts, nil
	}
	if opts.MatchStrategy != core.MatchStrategyFts {
		return opts, fmt.Errorf("--mention can only be used with --match-strategy=fts")
	}

	// Find the IDs for the mentioned paths.
	ids, err := d.findIDsByHrefs(opts.Mention, true /* allowPartialHrefs */)
	if err != nil {
		return opts, err
	}
	if len(ids) == 0 {
		return opts, fmt.Errorf("could not find notes at: %s", strings.Join(opts.Mention, ", "))
	}

	// Exclude the mentioned notes from the results.
	opts = opts.ExcludingIDs(ids)

	// Find their titles.
	titlesQuery := "SELECT title, metadata FROM notes WHERE id IN (" + joinNoteIDs(ids, ",") + ")"
	rows, err := d.tx.Query(titlesQuery)
	if err != nil {
		return opts, err
	}
	defer rows.Close()

	mentionQueries := []string{}

	for rows.Next() {
		var title, metadataJSON string
		err := rows.Scan(&title, &metadataJSON)
		if err != nil {
			return opts, err
		}

		mentionQueries = append(mentionQueries, buildMentionQuery(title, metadataJSON))
	}

	if len(mentionQueries) == 0 {
		return opts, nil
	}

	// Expand the mention queries in the match predicate.
	opts.Match = append(opts.Match, " ("+strings.Join(mentionQueries, " OR ")+") ")

	return opts, nil
}

// noteSelection represents the amount of column selected with findRows.
type noteSelection int

const (
	noteSelectionID noteSelection = iota + 1
	noteSelectionMinimal
	noteSelectionFull
)

func (d *NoteDAO) findRows(opts core.NoteFindOpts, selection noteSelection) (*sql.Rows, error) {
	snippetCol := `n.lead`
	joinClauses := []string{}
	whereExprs := []string{}
	additionalOrderTerms := []string{}
	args := []any{}
	groupBy := ""

	transitiveClosure := false
	maxDistance := 0

	setupLinkFilter := func(tableAlias string, hrefs []string, direction int, negate, recursive bool) error {
		ids, err := d.findIDsByHrefs(hrefs, true /* allowPartialHrefs */)
		if err != nil {
			return err
		}
		if len(ids) == 0 {
			return fmt.Errorf("could not find notes at: %s", strings.Join(hrefs, ", "))
		}
		idsList := "(" + joinNoteIDs(ids, ",") + ")"

		linksSrc := "links"

		if recursive {
			transitiveClosure = true
			linksSrc = "transitive_closure"
			additionalOrderTerms = append(additionalOrderTerms, tableAlias+".distance")
		}

		if !negate {
			if direction != 0 {
				snippetCol = fmt.Sprintf("GROUP_CONCAT(REPLACE(%s.snippet, %[1]s.title, '<zk:match>' || %[1]s.title || '</zk:match>'), '\x01')", tableAlias)
			}

			joinOns := make([]string, 0)
			if direction <= 0 {
				joinOns = append(joinOns, fmt.Sprintf(
					"(n.id = %[1]s.target_id AND %[1]s.source_id IN %[2]s)", tableAlias, idsList,
				))
			}
			if direction >= 0 {
				joinOns = append(joinOns, fmt.Sprintf(
					"(n.id = %[1]s.source_id AND %[1]s.target_id IN %[2]s)", tableAlias, idsList,
				))
			}

			joinClauses = append(joinClauses, fmt.Sprintf(
				"LEFT JOIN %[2]s %[1]s ON %[3]s",
				tableAlias,
				linksSrc,
				strings.Join(joinOns, " OR "),
			))

			groupBy = "GROUP BY n.id"
		}

		idExpr := "n.id"
		if negate {
			idExpr += " NOT"
		}

		idSelects := make([]string, 0)
		if direction <= 0 {
			idSelects = append(idSelects, fmt.Sprintf(
				"    SELECT target_id FROM %s WHERE target_id IS NOT NULL AND source_id IN %s",
				linksSrc, idsList,
			))
		}
		if direction >= 0 {
			idSelects = append(idSelects, fmt.Sprintf(
				"    SELECT source_id FROM %s WHERE target_id IS NOT NULL AND target_id IN %s",
				linksSrc, idsList,
			))
		}

		idExpr += " IN (\n" + strings.Join(idSelects, "\n    UNION\n") + "\n)"

		whereExprs = append(whereExprs, idExpr)

		return nil
	}

	if 0 < len(opts.Match) {
		switch opts.MatchStrategy {
		case core.MatchStrategyExact:
			for _, match := range opts.Match {
				whereExprs = append(whereExprs, `n.raw_content LIKE '%' || ? || '%' ESCAPE '\'`)
				args = append(args, escapeLikeTerm(match, '\\'))
			}
		case core.MatchStrategyFts:
			snippetCol = `snippet(fts_match.notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
			joinClauses = append(joinClauses, "JOIN notes_fts fts_match ON n.id = fts_match.rowid")
			additionalOrderTerms = append(additionalOrderTerms, `bm25(fts_match.notes_fts, 1000.0, 500.0, 1.0)`)
			for _, match := range opts.Match {
				whereExprs = append(whereExprs, "fts_match.notes_fts MATCH ?")
				args = append(args, fts5.ConvertQuery(match))
			}
		case core.MatchStrategyRe:
			for _, match := range opts.Match {
				whereExprs = append(whereExprs, "n.raw_content REGEXP ?")
				args = append(args, match)
			}
		}
	}

	if opts.IncludeHrefs != nil {
		ids, err := d.findIDsByHrefs(opts.IncludeHrefs, opts.AllowPartialHrefs)
		if err != nil {
			return nil, err
		}
		opts = opts.IncludingIDs(ids)
	}

	if opts.ExcludeHrefs != nil {
		ids, err := d.findIDsByHrefs(opts.ExcludeHrefs, opts.AllowPartialHrefs)
		if err != nil {
			return nil, err
		}
		opts = opts.ExcludingIDs(ids)
	}

	if opts.Tags != nil {
		separatorRegex := regexp.MustCompile(`(\ OR\ )|\|`)
		for _, tagsArg := range opts.Tags {
			tags := separatorRegex.Split(tagsArg, -1)

			negate := false
			globs := make([]string, 0)
			for _, tag := range tags {
				tag = strings.TrimSpace(tag)

				if strings.HasPrefix(tag, "-") {
					negate = true
					tag = strings.TrimPrefix(tag, "-")
				} else if strings.HasPrefix(tag, "NOT") {
					negate = true
					tag = strings.TrimPrefix(tag, "NOT")
				}

				tag = strings.TrimSpace(tag)
				if len(tag) == 0 {
					continue
				}
				globs = append(globs, "t.name GLOB ?")
				args = append(args, tag)
			}

			if len(globs) == 0 {
				continue
			}
			if negate && len(globs) > 1 {
				return nil, fmt.Errorf("cannot negate a tag in a OR group: %s", tagsArg)
			}

			expr := "n.id"
			if negate {
				expr += " NOT"
			}
			expr += fmt.Sprintf(` IN (
SELECT note_id FROM notes_collections
WHERE collection_id IN (SELECT id FROM collections t WHERE kind = '%s' AND (%s))
)`,
				core.CollectionKindTag,
				strings.Join(globs, " OR "),
			)
			whereExprs = append(whereExprs, expr)
		}
	}

	if opts.MentionedBy != nil {
		ids, err := d.findIDsByHrefs(opts.MentionedBy, true /* allowPartialHrefs */)
		if err != nil {
			return nil, err
		}
		if len(ids) == 0 {
			return nil, fmt.Errorf("could not find notes at: %s", strings.Join(opts.MentionedBy, ", "))
		}

		// Exclude the mentioning notes from the results.
		opts = opts.ExcludingIDs(ids)

		snippetCol = `snippet(nsrc.notes_fts, 2, '<zk:match>', '</zk:match>', '…', 20)`
		joinClauses = append(joinClauses, "JOIN notes_fts nsrc ON nsrc.rowid IN ("+joinNoteIDs(ids, ",")+") AND nsrc.notes_fts MATCH mention_query(n.title, n.metadata)")
	}

	if opts.LinkedBy != nil {
		filter := opts.LinkedBy
		maxDistance = filter.MaxDistance
		err := setupLinkFilter("l_by", filter.Hrefs, -1, filter.Negate, filter.Recursive)
		if err != nil {
			return nil, err
		}
	}

	if opts.LinkTo != nil {
		filter := opts.LinkTo
		maxDistance = filter.MaxDistance
		err := setupLinkFilter("l_to", filter.Hrefs, 1, filter.Negate, filter.Recursive)
		if err != nil {
			return nil, err
		}
	}

	if opts.Related != nil {
		maxDistance = 2
		err := setupLinkFilter("l_rel", opts.Related, 0, false, true)
		if err != nil {
			return nil, err
		}
		groupBy += " HAVING MIN(l_rel.distance) = 2"
	}

	if opts.Orphan {
		whereExprs = append(whereExprs, `n.id NOT IN (
			SELECT target_id FROM links WHERE target_id IS NOT NULL
		)`)
	}

	if opts.Tagless {
		whereExprs = append(whereExprs, `tags IS NULL`)
	}

	if opts.MissingBacklink {
		whereExprs = append(whereExprs, `n.id IN (
			SELECT DISTINCT incoming.target_id
			FROM links incoming
			LEFT JOIN links outgoing ON (
				outgoing.source_id = incoming.target_id
				AND outgoing.target_id = incoming.source_id
			)
			WHERE incoming.target_id IS NOT NULL
			  AND outgoing.source_id IS NULL
		)`)
	}

	if opts.BrokenLinks {
		whereExprs = append(whereExprs, `n.id IN (
			SELECT DISTINCT source_id
			FROM links
			WHERE external = 0 AND target_id IS NULL
		)`)
	}

	if opts.CreatedStart != nil {
		whereExprs = append(whereExprs, "created >= ?")
		args = append(args, opts.CreatedStart)
	}

	if opts.CreatedEnd != nil {
		whereExprs = append(whereExprs, "created < ?")
		args = append(args, opts.CreatedEnd)
	}

	if opts.ModifiedStart != nil {
		whereExprs = append(whereExprs, "modified >= ?")
		args = append(args, opts.ModifiedStart)
	}

	if opts.ModifiedEnd != nil {
		whereExprs = append(whereExprs, "modified < ?")
		args = append(args, opts.ModifiedEnd)
	}

	if opts.IncludeIDs != nil {
		whereExprs = append(whereExprs, "n.id IN ("+joinNoteIDs(opts.IncludeIDs, ",")+")")
	}

	if opts.ExcludeIDs != nil {
		whereExprs = append(whereExprs, "n.id NOT IN ("+joinNoteIDs(opts.ExcludeIDs, ",")+")")
	}

	orderTerms := []string{}
	for _, sorter := range opts.Sorters {
		orderTerms = append(orderTerms, orderTerm(sorter))
	}
	orderTerms = append(orderTerms, additionalOrderTerms...)
	orderTerms = append(orderTerms, `n.title ASC`)

	var query strings.Builder

	// Credit to https://inviqa.com/blog/storing-graphs-database-sql-meets-social-network
	if transitiveClosure {
		query.WriteString(`WITH RECURSIVE transitive_closure(source_id, target_id, title, snippet, distance, path) AS (
    SELECT source_id, target_id, title, snippet,
           1 AS distance,
           '.' || source_id || '.' || target_id || '.' AS path
      FROM links
 
     UNION ALL
 
    SELECT tc.source_id, l.target_id, l.title, l.snippet,
           tc.distance + 1,
           tc.path || l.target_id || '.' AS path
      FROM links AS l
      JOIN transitive_closure AS tc
        ON l.source_id = tc.target_id
     WHERE tc.path NOT LIKE '%.' || l.target_id || '.%'`)

		if maxDistance != 0 {
			fmt.Fprintf(&query, " AND tc.distance < %d", maxDistance)
		}

		// Guard against infinite loops by limiting the number of recursions.
		query.WriteString("\n     LIMIT 100000")

		query.WriteString("\n)\n")
	}

	query.WriteString("SELECT n.id")
	if selection != noteSelectionID {
		query.WriteString(", n.path, n.title, n.metadata")
		if selection != noteSelectionMinimal {
			fmt.Fprintf(&query, ", n.lead, n.body, n.raw_content, n.word_count, n.created, n.modified, n.checksum, n.tags, %s AS snippet", snippetCol)
		}
	}

	query.WriteString("\nFROM notes_with_metadata n\n")

	for _, clause := range joinClauses {
		query.WriteString(clause + "\n")
	}

	if len(whereExprs) > 0 {
		query.WriteString("WHERE " + strings.Join(whereExprs, "\nAND ") + "\n")
	}

	if groupBy != "" {
		query.WriteString(groupBy + "\n")
	}

	query.WriteString("ORDER BY " + strings.Join(orderTerms, ", ") + "\n")

	if opts.Limit > 0 {
		fmt.Fprintf(&query, "LIMIT %d\n", opts.Limit)
	}

	// d.logger.Println(query)
	// d.logger.Println(args)

	return d.tx.Query(query.String(), args...)
}

func (d *NoteDAO) scanMinimalNote(row RowScanner) (*core.MinimalNote, error) {
	var (
		id                        int
		path, title, metadataJSON string
	)

	err := row.Scan(&id, &path, &title, &metadataJSON)
	switch {
	case err == sql.ErrNoRows:
		return nil, nil
	case err != nil:
		return nil, err
	default:
		metadata, err := unmarshalMetadata(metadataJSON)
		if err != nil {
			d.logger.Err(fmt.Errorf("%s: %w", path, err))
		}

		return &core.MinimalNote{
			ID:       core.NoteID(id),
			Path:     path,
			Title:    title,
			Metadata: metadata,
		}, nil
	}
}

func (d *NoteDAO) scanNote(row RowScanner) (*core.ContextualNote, error) {
	var (
		id, wordCount                 int
		title, lead, body, rawContent string
		snippets, tags                sql.NullString
		path, metadataJSON, checksum  string
		created, modified             time.Time
	)

	err := row.Scan(
		&id, &path, &title, &metadataJSON, &lead, &body, &rawContent,
		&wordCount, &created, &modified, &checksum, &tags, &snippets,
	)
	switch {
	case err == sql.ErrNoRows:
		return nil, nil
	case err != nil:
		return nil, err
	default:
		metadata, err := unmarshalMetadata(metadataJSON)
		if err != nil {
			d.logger.Err(fmt.Errorf("%s: %w", path, err))
		}

		return &core.ContextualNote{
			Snippets: parseListFromNullString(snippets),
			Note: core.Note{
				ID:         core.NoteID(id),
				Path:       path,
				Title:      title,
				Lead:       lead,
				Body:       body,
				RawContent: rawContent,
				WordCount:  wordCount,
				Links:      []core.Link{},
				Tags:       parseListFromNullString(tags),
				Metadata:   metadata,
				Created:    created,
				Modified:   modified,
				Checksum:   checksum,
			},
		}, nil
	}
}

func orderTerm(sorter core.NoteSorter) string {
	order := " ASC"
	if !sorter.Ascending {
		order = " DESC"
	}

	switch sorter.Field {
	case core.NoteSortCreated:
		return "n.created" + order
	case core.NoteSortModified:
		return "n.modified" + order
	case core.NoteSortPath:
		return "n.path" + order
	case core.NoteSortRandom:
		return "RANDOM()"
	case core.NoteSortTitle:
		return "n.title" + order
	case core.NoteSortWordCount:
		return "n.word_count" + order
	default:
		panic(fmt.Sprintf("%v: unknown core.NoteSortField", sorter.Field))
	}
}

// buildMentionQuery creates an FTS5 predicate to match the given note's title
// (or aliases from the metadata) in the content of another note.
//
// It is exposed as a custom SQLite function as `mention_query()`.
func buildMentionQuery(title, metadataJSON string) string {
	titles := []string{}

	appendTitle := func(t string) {
		t = strings.TrimSpace(t)
		if t != "" {
			// Remove double quotes in the title to avoid tripping the FTS5 parser.
			titles = append(titles, `"`+strings.ReplaceAll(t, `"`, "")+`"`)
		}
	}

	appendTitle(title)

	// Support `aliases` key in the YAML frontmatter, like Obsidian:
	// https://publish.obsidian.md/help/How+to/Add+aliases+to+note
	metadata, err := unmarshalMetadata(metadataJSON)
	if err == nil {
		if aliases, ok := metadata["aliases"]; ok {
			switch aliases := aliases.(type) {
			case []any:
				for _, alias := range aliases {
					appendTitle(fmt.Sprint(alias))
				}
			case string:
				appendTitle(aliases)
			}
		}
	}

	if len(titles) == 0 {
		// Return an arbitrary search term otherwise MATCH will find every note.
		// Not proud of this hack but it does the job.
		return "8b80252291ee418289cfc9968eb2961c"
	}

	return "(" + strings.Join(titles, " OR ") + ")"
}
