import * as path from "node:path";
import matter from "gray-matter";
import type { FtsIndex } from "../fts5.js";
import type { FileEntry, Vault } from "../vault.js";
import { getBacklinks, getRecentEdits, listTags } from "./read.js";
import { searchHybrid } from "./search.js";
import { resolveTarget, suggestSimilar } from "./write.js";

// ─── obsidian_validate_note_proposal (v0.12 anti-slop validator) ─────────────
// Closes the #1 user-pain finding: LLM-generated notes arrive structurally
// broken — bad YAML, fake wikilinks, inconsistent tags — and users spend
// minutes reformatting per note. This tool is called BEFORE create/append:
// the LLM proposes a draft, we lint it against the live vault, return
// errors/warnings/suggestions, and the LLM can fix-and-retry without ever
// writing a broken note.

/**
 * Arguments for {@link validateNoteProposal}.
 *
 * Pre-write validator — never mutates disk regardless of `mode`. The mode
 * only controls how the validator reports path collisions.
 */
export interface ValidateProposalArgs {
  /** Vault-relative path the LLM intends to write to (e.g. "Inbox/idea.md"). */
  path: string;
  /** Full proposed markdown content including any frontmatter block. */
  content: string;
  /** "create" (default) → fail if path exists. "overwrite" / "append" → ok if exists. */
  mode?: "create" | "overwrite" | "append";
}

/**
 * Structured validation report returned by {@link validateNoteProposal}.
 *
 * `ok` is true iff `errors` is empty — warnings don't block the agent.
 * `wikilinks[*].suggestions` carry did-you-mean hints for broken links so
 * the LLM can fix-and-retry without writing a broken note.
 */
export interface ValidateProposalResult {
  ok: boolean;
  proposed_path: string;
  mode: "create" | "overwrite" | "append";
  errors: Array<{ kind: string; message: string }>;
  warnings: Array<{ kind: string; message: string; suggestion?: string }>;
  yaml: {
    parsed: boolean;
    error: string | null;
    keys: string[];
  };
  wikilinks: Array<{
    raw: string;
    target: string;
    status: "resolved" | "broken" | "ambiguous";
    resolved_path: string | null;
    suggestions: string[];
  }>;
  tags: Array<{
    name: string;
    status: "existing" | "new";
  }>;
  collision: {
    kind: "none" | "path-exists" | "title-exists-elsewhere";
    existing_path?: string;
  };
}

/**
 * Pre-write validator — lint an LLM-proposed note against the live vault
 * before writing.
 *
 * The "anti-slop" first call: the LLM proposes a draft, this validator
 * checks YAML / wikilinks / tags / path-collision against the vault, and
 * returns structured `errors[]` + `warnings[]`. The LLM can fix-and-retry
 * via the same call, finally invoking {@link createNote} / {@link appendToNote}
 * only after the validator reports `ok: true`. Read-only — never mutates
 * disk. Always returns a structured result for ANY input, even malformed —
 * path-traversal errors become `kind: "path-traversal"` errors rather than
 * exceptions.
 *
 * v3.7.16 P2-14 — errors[] now includes `path-excluded` when the
 * proposed destination is blocked by `--exclude-glob` / `--read-paths`.
 * Pre-3.7.16 the validator passed structurally-valid proposals into
 * excluded destinations; the actual write would then fail at runtime.
 * Pre-write parity with `writeNote` / `createNote` is the new contract.
 *
 * @param vault - The vault.
 * @param args - {@link ValidateProposalArgs}. `path` + `content` required.
 * @returns A {@link ValidateProposalResult} with `ok`, `errors`, `warnings`,
 *   YAML parse status, per-wikilink resolution, tag classification, and
 *   collision detection. Possible `errors[].kind` values include
 *   `path-traversal`, `path-excluded` (v3.7.16+), `yaml-invalid`,
 *   plus the wikilink / tag / collision categories.
 * @example
 * ```ts
 * const v = await validateNoteProposal(vault, {
 *   path: "Inbox/draft.md",
 *   content: "---\nstatus: draft\n---\n# Title\n\n[[Bar]] is broken.",
 *   mode: "create"
 * });
 * if (!v.ok) {
 *   for (const e of v.errors) console.error(e.kind, e.message);
 * }
 * ```
 */
export async function validateNoteProposal(vault: Vault, args: ValidateProposalArgs): Promise<ValidateProposalResult> {
  await vault.ensureExists();
  const mode = args.mode ?? "create";
  const errors: Array<{ kind: string; message: string }> = [];
  const warnings: Array<{ kind: string; message: string; suggestion?: string }> = [];

  // 1. Path sanity. resolveInside throws on traversal — capture as error,
  //    don't let it propagate as a generic exception (the validator should
  //    return a structured result for ANY input).
  let normalizedPath = args.path.toLowerCase().endsWith(".md") ? args.path : `${args.path}.md`;
  let absPath: string | null = null;
  try {
    absPath = vault.resolveInside(normalizedPath);
    normalizedPath = vault.toRel(absPath);
  } catch (err) {
    errors.push({
      kind: "path-traversal",
      message: err instanceof Error ? err.message : String(err)
    });
  }

  // v3.7.16 P2-14 — privacy-filter check. Pre-3.7.16 the validator only
  // checked structural concerns (traversal, YAML, wikilinks) and gave a
  // green light for proposals into excluded destinations — the actual
  // write would then fail at runtime with "destination is excluded by ...".
  // The pre-write validator is supposed to be the dry-run check before
  // calling createNote/appendToNote, so it should return the privacy
  // verdict too. Adds `path-excluded` error class.
  if (absPath !== null) {
    const exclusion = vault.exclusionReason(normalizedPath);
    if (exclusion !== null) {
      errors.push({
        kind: "path-excluded",
        message: `Destination is excluded by ${exclusion}: ${normalizedPath}`
      });
    }
  }

  // 2. YAML parse via gray-matter (the same parser used at write time).
  const yamlReport = { parsed: false, error: null as string | null, keys: [] as string[] };
  let bodyAfterFm = args.content;
  try {
    const parsed = matter(args.content);
    yamlReport.parsed = true;
    yamlReport.keys = Object.keys(parsed.data ?? {});
    bodyAfterFm = parsed.content;
  } catch (err) {
    yamlReport.error = err instanceof Error ? err.message : String(err);
    errors.push({ kind: "yaml-invalid", message: `YAML frontmatter could not be parsed: ${yamlReport.error}` });
  }

  // 3. Wikilink resolution against the live vault.
  const all = await vault.listMarkdown();
  const wikilinkRe = /(?<!!)\[\[([^\]\n]+?)\]\]/g;
  const wikilinks: ValidateProposalResult["wikilinks"] = [];
  for (const m of bodyAfterFm.matchAll(wikilinkRe)) {
    const raw = m[0];
    const inner = (m[1] ?? "").trim();
    if (!inner) continue;
    // Strip alias / section / block to get the bare target name.
    const beforePipe = inner.split("|")[0] ?? "";
    const beforeHash = beforePipe.split("#")[0] ?? "";
    const target = beforeHash.split("^")[0]?.trim() ?? "";
    if (!target) continue;
    const match = findBestMatch(all, target, normalizedPath);
    if (match) {
      wikilinks.push({
        raw,
        target,
        status: "resolved",
        resolved_path: match.relPath,
        suggestions: []
      });
    } else {
      const suggestions = await suggestSimilar(vault, target);
      wikilinks.push({
        raw,
        target,
        status: "broken",
        resolved_path: null,
        suggestions
      });
      warnings.push({
        kind: "broken-wikilink",
        message: `[[${target}]] does not resolve to any existing note`,
        suggestion: suggestions.length ? `Closest matches: ${suggestions.join(", ")}` : undefined
      });
    }
  }

  // 4. Tag pre-classification (existing vs new).
  const existingTags = new Set((await listTags(vault, {})).map((t) => t.tag.toLowerCase()));
  const proposedTagsRaw = new Set<string>();
  // Frontmatter tags.
  const fmData = yamlReport.parsed ? matter(args.content).data : {};
  const fmTags = fmData.tags ?? fmData.tag;
  if (Array.isArray(fmTags)) {
    for (const t of fmTags) if (typeof t === "string" && t) proposedTagsRaw.add(t.replace(/^#/, ""));
  } else if (typeof fmTags === "string" && fmTags) {
    for (const t of fmTags.split(/[\s,]+/)) if (t) proposedTagsRaw.add(t.replace(/^#/, ""));
  }
  // Inline tags.
  const inlineTagRe = /(?:^|[\s([{>])#([\p{L}][\p{L}\p{N}_/-]*)/gu;
  for (const m of bodyAfterFm.matchAll(inlineTagRe)) {
    if (m[1]) proposedTagsRaw.add(m[1]);
  }
  const tags: ValidateProposalResult["tags"] = [];
  for (const t of proposedTagsRaw) {
    const status = existingTags.has(t.toLowerCase()) ? "existing" : "new";
    tags.push({ name: t, status });
    if (status === "new") {
      warnings.push({
        kind: "new-tag",
        message: `#${t} is new — won't fork an existing tag (case-insensitive check)`
      });
    }
  }

  // 5. Path collision check.
  let collision: ValidateProposalResult["collision"] = { kind: "none" };
  if (absPath) {
    try {
      await vault.stat(absPath);
      // Path exists.
      if (mode === "create") {
        errors.push({
          kind: "path-collision",
          message: `Note already exists at ${normalizedPath} (mode="create" refuses overwrite)`
        });
      }
      collision = { kind: "path-exists", existing_path: normalizedPath };
    } catch {
      // Path doesn't exist — try title collision (an existing note at a different path).
      const titleFromBasename = stripMd(path.basename(normalizedPath));
      const existing = await vault.findByTitle(titleFromBasename);
      if (existing && existing.relPath !== normalizedPath) {
        warnings.push({
          kind: "title-collision",
          message: `A note titled "${titleFromBasename}" already exists at ${existing.relPath} — proceeding will create a same-titled file at a different path`,
          suggestion: existing.relPath
        });
        collision = { kind: "title-exists-elsewhere", existing_path: existing.relPath };
      }
    }
  }

  return {
    ok: errors.length === 0,
    proposed_path: normalizedPath,
    mode,
    errors,
    warnings,
    yaml: yamlReport,
    wikilinks,
    tags,
    collision
  };
}

// ─── obsidian_lint_wiki (v1.5 — Karpathy LLM-Wiki lint workflow) ─────────────
// Karpathy's gist (gist.github.com/karpathy/442a6bf555914893e9891c11519de94f)
// names three workflows for an LLM-maintained wiki: ingest, query, lint. We had
// the ingest+query primitives (create_note + search/find_similar/etc.) since
// 0.13. lint completes the trio in one tool call: orphans, broken links, stub
// pages, stale claims, and "concept mentioned in N+ notes but missing its own
// page." Each finding is shaped so the agent can fix it via existing tools
// (validate_note_proposal → create_note / append_to_note / rename_note).

/**
 * Arguments for {@link lintWiki}. All optional with sensible defaults.
 */
export interface LintWikiArgs {
  /** Folder to restrict the lint to (default: whole vault). */
  folder?: string;
  /** Word count below which a note is considered a "stub". Default 100. */
  stub_word_threshold?: number;
  /** A note is "stale" if its frontmatter `last_reviewed` (or mtime if missing)
   *  is older than this many days. Default 365. */
  stale_days?: number;
  /** A capitalised n-gram mentioned by ≥ N distinct notes but not having its
   *  own page is flagged as a concept candidate. Default 3. */
  concept_min_mentions?: number;
  /** Cap on each finding-bucket so the response stays bounded. Default 50. */
  max_per_bucket?: number;
}

/**
 * One lint finding. `details` carries kind-specific context (word count for
 * stubs, mention sources for concepts, etc.). `suggestion` is an action
 * hint the agent can paraphrase to the user.
 */
export interface LintWikiFinding {
  /** Lint category. */
  kind: "orphan" | "broken-link" | "stub" | "stale" | "concept-without-page";
  /** Vault-relative path of the offending note (absent on concept candidates). */
  path?: string;
  /** Human-readable description of the issue. */
  message: string;
  /** Action hint for fixing. */
  suggestion?: string;
  /** Kind-specific payload (word_count, mention_count, sources, etc.). */
  details?: Record<string, unknown>;
}

/**
 * Envelope returned by {@link lintWiki}.
 *
 * `summary` carries truncated counts (capped at `max_per_bucket`); use it
 * for the agent's headline message. `findings` carries the per-issue list.
 */
export interface LintWikiResult {
  scope: string;
  scanned: number;
  generated_at: string;
  summary: {
    orphans: number;
    broken_links: number;
    stubs: number;
    stale: number;
    concept_candidates: number;
  };
  findings: {
    orphans: LintWikiFinding[];
    broken_links: LintWikiFinding[];
    stubs: LintWikiFinding[];
    stale: LintWikiFinding[];
    concept_candidates: LintWikiFinding[];
  };
}

/**
 * Karpathy-style LLM-Wiki lint — single-call audit of orphans, broken
 * links, stubs, stale notes, and concept candidates.
 *
 * Implements the "lint" workflow from Karpathy's LLM-Wiki gist (which named
 * ingest/query/lint as the three primitives). Returns five finding buckets,
 * each capped to `max_per_bucket` for bounded responses. Findings are
 * shaped so the agent can act on them via existing tools
 * ({@link validateNoteProposal} → {@link createNote} / {@link appendToNote} /
 * {@link renameNote}).
 *
 * Concept candidates use a capitalised-phrase heuristic: 1-3 CapitalCase
 * tokens that appear in ≥ `concept_min_mentions` notes but don't have a
 * page of their own. Stop-words ("The", "This", etc.) at phrase start are
 * dropped.
 *
 * @param vault - The vault.
 * @param args - {@link LintWikiArgs}. All optional with documented defaults.
 * @returns A {@link LintWikiResult} with summary counts + per-bucket findings.
 * @throws {VaultPathError} If `folder` resolves outside the vault.
 * @example
 * ```ts
 * const lint = await lintWiki(vault, {
 *   folder: "Wiki",
 *   stub_word_threshold: 50,
 *   stale_days: 180
 * });
 * console.log(`Orphans: ${lint.summary.orphans}, Stubs: ${lint.summary.stubs}`);
 * for (const f of lint.findings.broken_links) console.log(f.message);
 * ```
 */
export async function lintWiki(vault: Vault, args: LintWikiArgs): Promise<LintWikiResult> {
  await vault.ensureExists();
  const stubThreshold = args.stub_word_threshold ?? 100;
  const staleDays = args.stale_days ?? 365;
  const conceptMinMentions = args.concept_min_mentions ?? 3;
  const cap = args.max_per_bucket ?? 50;

  const entries = await vault.listMarkdown(args.folder);
  const allEntries = await vault.listMarkdown();
  const staleMs = Date.now() - staleDays * 24 * 3600 * 1000;

  // Single pass: collect inbound counts, outbound presence, broken links,
  // word counts, last-reviewed times, capitalised-phrase mentions.
  const inbound = new Map<string, number>();
  const outboundPresence = new Set<string>();
  const broken: LintWikiFinding[] = [];
  const stubs: LintWikiFinding[] = [];
  const stale: LintWikiFinding[] = [];
  const titleSet = new Set<string>();
  for (const e of allEntries) titleSet.add(stripMd(e.basename).toLowerCase());

  // Capitalised-phrase mentions across the whole vault. A phrase is 1-3
  // CapitalCase tokens (e.g. "Reinforcement Learning", "Attention Heads").
  // Stop-words: dropped when they appear at the start of a phrase.
  const conceptStopwords = new Set([
    "The",
    "A",
    "An",
    "This",
    "That",
    "These",
    "Those",
    "If",
    "When",
    "While",
    "But",
    "And",
    "Or"
  ]);
  const capPhraseRe = /\b((?:[A-Z][a-z][a-z]+(?:\s+[A-Z][a-z][a-z]+){0,2}))\b/g;
  const conceptMentions = new Map<string, Set<string>>(); // phrase → set of source paths

  for (const e of entries) {
    const { parsed, mtimeMs } = await vault.readNote(e.absPath, e.mtimeMs);

    // Outbound + broken pass.
    if (parsed.wikilinks.length > 0) outboundPresence.add(e.relPath);
    for (const link of parsed.wikilinks) {
      const m = findBestMatch(allEntries, link.target, e.relPath);
      if (m) {
        inbound.set(m.relPath, (inbound.get(m.relPath) ?? 0) + 1);
      } else if (broken.length < cap) {
        broken.push({
          kind: "broken-link",
          path: e.relPath,
          message: `[[${link.target}]] in ${e.relPath} doesn't resolve`,
          suggestion: "create the missing note, fix the link, or remove it",
          details: { target: link.target, raw: link.raw }
        });
      }
    }

    // Stub pass.
    const wordCount = parsed.body.trim() ? parsed.body.trim().split(/\s+/).length : 0;
    if (wordCount < stubThreshold && stubs.length < cap) {
      stubs.push({
        kind: "stub",
        path: e.relPath,
        message: `${e.relPath} is ${wordCount} words (threshold ${stubThreshold})`,
        suggestion: "develop, merge into a hub, or archive",
        details: { word_count: wordCount, mtime: new Date(mtimeMs).toISOString() }
      });
    }

    // Stale pass — frontmatter `last_reviewed` overrides mtime if present.
    // gray-matter (js-yaml) parses ISO dates into Date objects automatically,
    // so we accept Date | string | number.
    const lastReviewedRaw = parsed.frontmatter?.last_reviewed ?? parsed.frontmatter?.["last-reviewed"];
    let lastTouchedMs = mtimeMs;
    if (lastReviewedRaw instanceof Date) {
      const t = lastReviewedRaw.getTime();
      if (Number.isFinite(t)) lastTouchedMs = t;
    } else if (typeof lastReviewedRaw === "string") {
      const t = Date.parse(lastReviewedRaw);
      if (Number.isFinite(t)) lastTouchedMs = t;
    } else if (typeof lastReviewedRaw === "number" && Number.isFinite(lastReviewedRaw)) {
      lastTouchedMs = lastReviewedRaw;
    }
    if (lastTouchedMs < staleMs && stale.length < cap) {
      stale.push({
        kind: "stale",
        path: e.relPath,
        message: `${e.relPath} not touched since ${new Date(lastTouchedMs).toISOString().slice(0, 10)}`,
        suggestion: "review for accuracy or archive",
        details: {
          last_touched: new Date(lastTouchedMs).toISOString(),
          source: lastReviewedRaw !== undefined ? "frontmatter.last_reviewed" : "mtime"
        }
      });
    }

    // Concept-mention pass — capitalised phrases in the body that aren't
    // already a wikilink target. Cap at 30 unique phrases per source to
    // bound memory, but loose enough that real concepts in long notes don't
    // get truncated.
    const seenInThisNote = new Set<string>();
    for (const m of parsed.body.matchAll(capPhraseRe)) {
      const phrase = m[1];
      if (!phrase) continue;
      const firstWord = phrase.split(/\s+/)[0];
      if (firstWord !== undefined && conceptStopwords.has(firstWord)) continue;
      if (seenInThisNote.has(phrase)) continue;
      if (seenInThisNote.size >= 30) break;
      // Skip phrases that are already a vault note (basename match).
      if (titleSet.has(phrase.toLowerCase())) continue;
      seenInThisNote.add(phrase);
      const set = conceptMentions.get(phrase) ?? new Set<string>();
      set.add(e.relPath);
      conceptMentions.set(phrase, set);
    }
  }

  // Orphan findings (no inbound AND no outbound).
  const orphans: LintWikiFinding[] = [];
  for (const e of entries) {
    if (orphans.length >= cap) break;
    if (!inbound.get(e.relPath) && !outboundPresence.has(e.relPath)) {
      orphans.push({
        kind: "orphan",
        path: e.relPath,
        message: `${e.relPath} has no inbound or outbound wikilinks`,
        suggestion: "link from a hub note, archive, or delete",
        details: { mtime: new Date(e.mtimeMs).toISOString() }
      });
    }
  }

  // Concept candidates — phrases mentioned by ≥ N distinct notes.
  const conceptCandidates: LintWikiFinding[] = [];
  const ranked = [...conceptMentions.entries()]
    .filter(([, sources]) => sources.size >= conceptMinMentions)
    .sort((a, b) => b[1].size - a[1].size);
  for (const [phrase, sources] of ranked) {
    if (conceptCandidates.length >= cap) break;
    conceptCandidates.push({
      kind: "concept-without-page",
      message: `"${phrase}" is mentioned by ${sources.size} notes but has no page of its own`,
      suggestion: `create a page \`${phrase}.md\` and refile the most-developed mentions into it`,
      details: { phrase, mention_count: sources.size, sources: [...sources].slice(0, 5) }
    });
  }

  return {
    scope: args.folder ?? "(whole vault)",
    scanned: entries.length,
    generated_at: new Date().toISOString(),
    summary: {
      orphans: orphans.length,
      broken_links: broken.length,
      stubs: stubs.length,
      stale: stale.length,
      concept_candidates: conceptCandidates.length
    },
    findings: {
      orphans,
      broken_links: broken,
      stubs,
      stale,
      concept_candidates: conceptCandidates
    }
  };
}

// ─── obsidian_open_questions (v1.5 — surface unresolved threads) ─────────────
// Karpathy and other ML PKM workflows use "Open question:" / "Q:" / "TODO?" /
// "??" lines as deferred-thinking markers. This tool returns every such line
// across the vault with source + context heading + age, sorted oldest-first.

/**
 * One open question / TODO marker surfaced by {@link getOpenQuestions}.
 *
 * `context_heading` is the nearest preceding heading (any level) — useful
 * for context-locating the question without reading the surrounding note.
 * `age_days` is computed from the note's mtime, so it surfaces questions
 * that have aged without being touched.
 */
export interface OpenQuestion {
  /** Trimmed text of the question (capture group of the matcher). */
  question: string;
  /** Vault-relative path of the source note. */
  source_path: string;
  /** `.md`-stripped basename for display. */
  source_title: string;
  /** Nearest preceding heading, or null if at top of file. */
  context_heading: string | null;
  /** 1-based line number where the question appears. */
  line: number;
  /** Rounded days since the source note was last modified. */
  age_days: number;
  /** ISO-8601 mtime of the source note. */
  mtime: string;
}

/**
 * Surface unresolved threads — `Open question:` / `Q:` / `TODO?` / `??`
 * markers across the vault.
 *
 * Karpathy and ML PKM workflows use these as deferred-thinking markers.
 * This tool returns every such line with source path, context heading,
 * and `age_days` for staleness ranking. Sorted oldest-first so aging
 * questions surface for the agent to nudge the user about.
 *
 * Scans `parsed.body` (frontmatter excluded) so YAML lines containing
 * "Q:"-ish tokens don't pollute results. The default matcher is
 * case-insensitive and accepts list-bullets / quote / heading prefixes
 * before the marker. Override `pattern` for a custom regex.
 *
 * @param vault - The vault.
 * @param args - All optional. `folder` restricts the scan. `limit`
 *   defaults to 100. `pattern` overrides the default matcher regex.
 * @returns Sorted `OpenQuestion[]` (oldest-first by `age_days`).
 * @throws {VaultPathError} If `folder` resolves outside the vault.
 * @example
 * ```ts
 * const qs = await getOpenQuestions(vault, { folder: "Reading", limit: 30 });
 * for (const q of qs.slice(0, 5)) {
 *   console.log(`${q.source_path}:${q.line} [${q.age_days}d ago] ${q.question}`);
 * }
 * ```
 */
export async function getOpenQuestions(
  vault: Vault,
  args: { folder?: string; limit?: number; pattern?: string }
): Promise<OpenQuestion[]> {
  await vault.ensureExists();
  const limit = args.limit ?? 100;
  // Default pattern: "Open question:" / "Open question -" / "Q:" / "TODO?" / "??"
  // followed by space + question text. Anchored at line start (with optional
  // list-bullet / quote / heading prefix).
  // Default pattern matches deferred-thinking markers at line start (with
  // optional list-bullet / quote / heading prefix). Single-line `i` flag —
  // we apply it line-by-line below.
  const defaultPat = "^\\s*(?:[#\\->\\*\\d\\.]+\\s+)?(?:open\\s+question|q|todo\\?|\\?\\?)\\s*[:\\-]?\\s*(.+)$";
  const re = new RegExp(args.pattern ?? defaultPat, "i");

  const entries = await vault.listMarkdown(args.folder);
  const out: OpenQuestion[] = [];
  const now = Date.now();
  for (const e of entries) {
    if (out.length >= limit) break;
    const { parsed, mtimeMs } = await vault.readNote(e.absPath, e.mtimeMs);
    // Scan parsed.body so frontmatter lines (which can contain "Q:" -ish
    // tokens) don't pollute results.
    const lines = parsed.body.split("\n");
    let currentHeading: string | null = null;
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i] ?? "";
      const headingMatch = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
      if (headingMatch?.[2]) {
        currentHeading = headingMatch[2];
        // A heading line itself isn't a question hit — skip the regex match.
        continue;
      }
      const m = re.exec(line);
      if (!m?.[1]) continue;
      out.push({
        question: m[1].trim(),
        source_path: e.relPath,
        source_title: stripMd(e.basename),
        context_heading: currentHeading,
        line: i + 1,
        age_days: Math.round((now - mtimeMs) / (24 * 3600 * 1000)),
        mtime: new Date(mtimeMs).toISOString()
      });
      if (out.length >= limit) break;
    }
  }
  // Sort oldest-first so things aging out surface first.
  out.sort((a, b) => b.age_days - a.age_days);
  return out;
}

// ─── obsidian_paper_audit (v1.5 — verify #paper notes have citations) ────────
// For each note tagged #paper (configurable), verify frontmatter has at least
// one of arxiv/doi/url/isbn. Also flag notes whose body contains an arxiv ID
// (e.g. "arxiv:2401.12345") but doesn't carry it in frontmatter — common after
// quick-capture from a chat.

/**
 * One flagged paper note returned by {@link paperAudit}.
 *
 * `proposed_frontmatter_patch` is a ready-to-apply YAML patch the agent
 * can hand to {@link frontmatterSet}. Null when the body has no detectable
 * identifiers either (the note really has no citation, manual fix needed).
 */
export interface PaperAuditFinding {
  /** Vault-relative path of the offending note. */
  path: string;
  /** `.md`-stripped basename for display. */
  title: string;
  /** Whether the note has any of arxiv/doi/url/isbn in frontmatter. */
  has_frontmatter_citation: boolean;
  /** Identifiers detected in body text (deduplicated, URLs capped at 3). */
  found_in_body: { arxiv: string[]; doi: string[]; url: string[] };
  /** A ready-to-apply `{ arxiv | doi | url }` patch — or null. */
  proposed_frontmatter_patch: Record<string, string> | null;
  /** Human-readable description of the issue. */
  message: string;
}

/**
 * Audit `#paper`-tagged notes for missing citation metadata.
 *
 * Scans every note carrying the configured tag and verifies frontmatter has
 * at least one of `arxiv` / `doi` / `url` / `isbn`. Notes with detectable
 * identifiers in body text (e.g. `arxiv:2401.12345` from quick-capture)
 * but missing frontmatter receive an actionable `proposed_frontmatter_patch`.
 *
 * @param vault - The vault.
 * @param args - All optional. `tag` defaults to `"paper"` (leading `#`
 *   stripped if provided). `folder` restricts the scan. `limit` defaults
 *   to 100.
 * @returns `{ scanned, flagged }` — `scanned` counts notes carrying the
 *   tag; `flagged` is the subset with missing frontmatter citation.
 * @throws {VaultPathError} If `folder` resolves outside the vault.
 * @example
 * ```ts
 * const audit = await paperAudit(vault, { tag: "paper", limit: 50 });
 * console.log(`${audit.flagged.length}/${audit.scanned} papers need citation`);
 * for (const p of audit.flagged) {
 *   if (p.proposed_frontmatter_patch) {
 *     await frontmatterSet(vault, { path: p.path, set: p.proposed_frontmatter_patch });
 *   }
 * }
 * ```
 */
export async function paperAudit(
  vault: Vault,
  args: { tag?: string; folder?: string; limit?: number }
): Promise<{ scanned: number; flagged: PaperAuditFinding[] }> {
  await vault.ensureExists();
  const tag = (args.tag ?? "paper").replace(/^#+/, "").toLowerCase();
  const limit = args.limit ?? 100;
  const entries = await vault.listMarkdown(args.folder);

  const arxivRe = /\barxiv[:\s]*([0-9]{4}\.[0-9]{4,5}(?:v\d+)?)\b/gi;
  const doiRe = /\bdoi[:\s]*(10\.\d{4,9}\/[\w\-._;()/:]+)/gi;
  const urlRe = /\bhttps?:\/\/[^\s<>")\]]+/g;

  let scanned = 0;
  const flagged: PaperAuditFinding[] = [];
  for (const e of entries) {
    if (flagged.length >= limit) break;
    const { parsed } = await vault.readNote(e.absPath, e.mtimeMs);
    const tagsLower = parsed.tags.map((t) => t.toLowerCase());
    if (!tagsLower.includes(tag)) continue;
    scanned += 1;

    const fm = parsed.frontmatter ?? {};
    const fmKeys = new Set(Object.keys(fm).map((k) => k.toLowerCase()));
    const hasFmCitation = fmKeys.has("arxiv") || fmKeys.has("doi") || fmKeys.has("url") || fmKeys.has("isbn");

    // Scan parsed.body so the frontmatter's own arxiv/doi keys don't get
    // re-detected as "found in body".
    const body = parsed.body;
    const arxivIds = [...body.matchAll(arxivRe)].map((m) => m[1]).filter((v): v is string => !!v);
    const doiIds = [...body.matchAll(doiRe)].map((m) => m[1]).filter((v): v is string => !!v);
    const urls = [...body.matchAll(urlRe)].map((m) => m[0]);
    const foundInBody = {
      arxiv: [...new Set(arxivIds)],
      doi: [...new Set(doiIds)],
      url: [...new Set(urls)].slice(0, 3)
    };

    const bodyHasAnyId = foundInBody.arxiv.length > 0 || foundInBody.doi.length > 0 || foundInBody.url.length > 0;
    // Clean ⇒ has a frontmatter citation. The body might cite OTHER papers,
    // but this note itself is properly identified.
    if (hasFmCitation) continue;

    let proposed: Record<string, string> | null = null;
    if (bodyHasAnyId) {
      proposed = {};
      if (foundInBody.arxiv[0]) proposed.arxiv = foundInBody.arxiv[0];
      if (foundInBody.doi[0]) proposed.doi = foundInBody.doi[0];
      if (foundInBody.url[0] && !proposed.arxiv && !proposed.doi) proposed.url = foundInBody.url[0];
    }

    const msg = bodyHasAnyId
      ? `${e.relPath} has identifiers in body (${[
          ...foundInBody.arxiv.map((v) => `arxiv:${v}`),
          ...foundInBody.doi.map((v) => `doi:${v}`)
        ]
          .slice(0, 2)
          .join(", ")}) but missing frontmatter`
      : `${e.relPath} has #${tag} but no arxiv/doi/url anywhere — citation missing`;

    flagged.push({
      path: e.relPath,
      title: stripMd(e.basename),
      has_frontmatter_citation: hasFmCitation,
      found_in_body: foundInBody,
      proposed_frontmatter_patch: proposed,
      message: msg
    });
  }
  return { scanned, flagged };
}

// ─── obsidian_find_path (v1.6 multi-hop graph traversal) ────────────────────
// BFS over the wikilink graph from `from` to `to`, returning the shortest path
// (sequence of notes connected by wikilinks) up to `max_depth` hops. Closes
// the gap aaronsb's plugin opened: "find paths between concepts" was the
// most-praised graph feature in the competitive audit. We use the shared
// EntryIndex memo so repeat calls in a session reuse the basename index for
// O(1) target resolution.

/**
 * One step of a graph traversal path returned by {@link findPath}.
 */
export interface PathStep {
  /** Vault-relative path of this step. */
  path: string;
  /** `.md`-stripped basename. */
  title: string;
  /** Wikilink raw text (`[[…]]` content) used to traverse FROM the previous
   *  step to this one. Empty on the source step. */
  via: string;
}

/**
 * Result of a {@link findPath} traversal.
 *
 * `found: false` + `hops: -1` indicates no path within `max_depth`.
 * `alternatives` is populated only when `args.include_alternatives` is true
 * and at least one same-length alternative exists.
 */
export interface FindPathResult {
  /** Source path (vault-relative, with `.md`). */
  from: string;
  /** Destination path. */
  to: string;
  /** Whether a path was found within `max_depth`. */
  found: boolean;
  /** Shortest path as a sequence of {@link PathStep}. Empty on miss. */
  path: PathStep[];
  /** Number of hops in `path`. -1 when not found. 0 for from = to. */
  hops: number;
  /** Up to 10 same-length alternatives, only when include_alternatives=true. */
  alternatives?: PathStep[][];
}

/**
 * Find the shortest wikilink-graph path between two notes via BFS.
 *
 * Multi-hop graph traversal — closes the gap the competitive audit
 * surfaced ("find paths between concepts" was the most-praised graph
 * feature in competitor plugins). Returns the shortest path up to
 * `max_depth` hops; with `include_alternatives: true`, also returns up
 * to 10 same-length alternatives (useful for "show me different
 * connections").
 *
 * Uses the shared `EntryIndex` (basename → entries map) for O(1) target
 * resolution per hop, so repeat calls in a session reuse the index.
 * v1.8.1 perf fix: builds a `relPath → entry` map once before BFS
 * (pre-fix was O(N²) per visited node).
 *
 * @param vault - The vault.
 * @param args - One of `from` / `from_title` and one of `to` / `to_title`
 *   required. `max_depth` defaults to 5. `include_alternatives` defaults
 *   to false. `follow_embeds` defaults to true (include `![[embeds]]` as
 *   edges).
 * @returns A {@link FindPathResult}. When `found: false`, `path: []` and
 *   `hops: -1`.
 * @throws {Error} If from / to can't be resolved.
 * @example
 * ```ts
 * const r = await findPath(vault, {
 *   from_title: "Attention",
 *   to_title: "RLHF",
 *   max_depth: 4
 * });
 * if (r.found) {
 *   console.log(`${r.hops} hops:`, r.path.map(s => s.title).join(" → "));
 * }
 * ```
 */
export async function findPath(
  vault: Vault,
  args: {
    from?: string;
    from_title?: string;
    to?: string;
    to_title?: string;
    max_depth?: number;
    include_alternatives?: boolean;
    follow_embeds?: boolean;
  }
): Promise<FindPathResult> {
  await vault.ensureExists();
  const maxDepth = args.max_depth ?? 5;
  const includeAlts = args.include_alternatives === true;
  const followEmbeds = args.follow_embeds !== false;

  const fromArgs: { path?: string; title?: string } = {};
  if (args.from !== undefined) fromArgs.path = args.from;
  else if (args.from_title !== undefined) fromArgs.title = args.from_title;
  const fromEntry = await resolveTarget(vault, fromArgs);

  const toArgs: { path?: string; title?: string } = {};
  if (args.to !== undefined) toArgs.path = args.to;
  else if (args.to_title !== undefined) toArgs.title = args.to_title;
  const toEntry = await resolveTarget(vault, toArgs);

  if (fromEntry.absPath === toEntry.absPath) {
    return {
      from: fromEntry.relPath,
      to: toEntry.relPath,
      found: true,
      hops: 0,
      path: [{ path: fromEntry.relPath, title: stripMd(fromEntry.basename), via: "" }]
    };
  }

  const entries = await vault.listMarkdown();

  // BFS layer-by-layer. visited tracks shortest-known-depth so we don't
  // revisit at greater depths. We continue collecting at the depth where
  // we first hit the target IF include_alternatives is set.
  // v1.8.1 perf fix: build a relPath → entry map ONCE before the BFS loop.
  // Pre-fix: entries.find((e) => e.relPath === node.rel) was O(N) per visited
  // node, making the whole BFS O(N²) on large vaults.
  const byRel = new Map<string, FileEntry>();
  for (const e of entries) byRel.set(e.relPath, e);

  type FrontierEntry = { rel: string; trail: PathStep[] };
  const visited = new Set<string>([fromEntry.relPath]);
  let frontier: FrontierEntry[] = [
    { rel: fromEntry.relPath, trail: [{ path: fromEntry.relPath, title: stripMd(fromEntry.basename), via: "" }] }
  ];
  const found: PathStep[][] = [];
  let foundDepth = -1;

  for (let depth = 0; depth < maxDepth && frontier.length > 0; depth++) {
    const next: FrontierEntry[] = [];
    for (const node of frontier) {
      const entry = byRel.get(node.rel);
      if (!entry) continue;
      const { parsed } = await vault.readNote(entry.absPath, entry.mtimeMs);
      const links = followEmbeds ? [...parsed.wikilinks, ...parsed.embeds] : parsed.wikilinks;
      for (const link of links) {
        const m = findBestMatch(entries, link.target, entry.relPath);
        if (!m) continue;
        if (visited.has(m.relPath) && m.absPath !== toEntry.absPath) continue;
        const newTrail: PathStep[] = [...node.trail, { path: m.relPath, title: stripMd(m.basename), via: link.raw }];
        if (m.absPath === toEntry.absPath) {
          if (foundDepth === -1) foundDepth = depth + 1;
          if (foundDepth === depth + 1) {
            found.push(newTrail);
            if (!includeAlts) {
              return {
                from: fromEntry.relPath,
                to: toEntry.relPath,
                found: true,
                hops: foundDepth,
                path: newTrail
              };
            }
          }
        } else {
          visited.add(m.relPath);
          next.push({ rel: m.relPath, trail: newTrail });
        }
      }
    }
    if (foundDepth !== -1 && depth + 1 === foundDepth) break;
    frontier = next;
  }

  if (found.length > 0) {
    found.sort((a, b) => a.length - b.length || (a[0]?.path ?? "").localeCompare(b[0]?.path ?? ""));
    const first = found[0];
    if (!first) {
      return { from: fromEntry.relPath, to: toEntry.relPath, found: false, hops: -1, path: [] };
    }
    const result: FindPathResult = {
      from: fromEntry.relPath,
      to: toEntry.relPath,
      found: true,
      hops: foundDepth,
      path: first
    };
    if (includeAlts) result.alternatives = found.slice(0, 10);
    return result;
  }

  return { from: fromEntry.relPath, to: toEntry.relPath, found: false, hops: -1, path: [] };
}

// ─── obsidian_open_in_ui (v1.6 cyanheads pattern) ───────────────────────────
// Returns an obsidian:// URI for hand-off to the desktop app. No filesystem or
// network side effect — the URI emission lets the agent say "open this in
// Obsidian" without enquire-mcp needing to coordinate with the running app.

/**
 * Result of {@link openInUi} — an `obsidian://` URI ready to hand off to
 * the desktop app.
 */
export interface OpenInUiResult {
  /** Full `obsidian://open?...` URI. */
  uri: string;
  /** Vault name as derived from the root folder leaf. */
  vault_name: string;
  /** Vault-relative path of the target note. */
  path: string;
  /** `.md`-stripped basename for display. */
  title: string;
}

/**
 * Emit an `obsidian://` URI for hand-off to the Obsidian desktop app.
 *
 * No filesystem or network side effect — the URI emission lets the agent
 * say "open this in Obsidian" without enquire-mcp needing to coordinate
 * with the running app (pattern from cyanheads' Obsidian MCP). The vault
 * name is the leaf of the vault root path; Obsidian matches by name OR by
 * file's absolute path, so this works even when the user opened the vault
 * under a different name client-side.
 *
 * @param vault - The vault.
 * @param args - One of `path` or `title` required. `new_pane: true` opens
 *   the note in a new pane.
 * @returns An {@link OpenInUiResult}. The caller is responsible for
 *   actually emitting the URI (e.g. via the MCP client surface or stdout).
 * @throws {Error} If target can't be resolved.
 * @example
 * ```ts
 * const r = await openInUi(vault, {
 *   path: "Reference/Article.md",
 *   new_pane: true
 * });
 * console.log(r.uri); // → obsidian://open?vault=Vault&file=Reference/Article&newpane=true
 * ```
 */
export async function openInUi(
  vault: Vault,
  args: { path?: string; title?: string; new_pane?: boolean }
): Promise<OpenInUiResult> {
  await vault.ensureExists();
  const target = await resolveTarget(vault, args);
  // Vault name = leaf of the vault root path. obsidian:// matches by name OR
  // by the file's absolute path; if the user opened the vault from a
  // different name in Obsidian, the file argument still resolves correctly.
  const vaultName = path.basename(vault.root);
  const noteRel = stripMd(target.relPath);
  const params = new URLSearchParams({ vault: vaultName, file: noteRel });
  if (args.new_pane) params.set("newpane", "true");
  return {
    uri: `obsidian://open?${params.toString()}`,
    vault_name: vaultName,
    path: target.relPath,
    title: stripMd(target.basename)
  };
}

// ─── obsidian_context_pack (v2.2.0 — token-budgeted vault context export) ───
// Smart Connections' "Send to Smart Context" pattern, MCP-native. Takes a
// query, runs hybrid retrieval, gathers note bodies + 1-line backlink
// summaries + recent daily notes, deduplicates, packs to a token budget,
// returns one ready-to-paste markdown string. The agent doesn't have to
// orchestrate 5 separate tool calls — one tool, one context blob.
//
// Why MCP-native > Obsidian-only: Smart Context only works inside Obsidian.
// This tool works in Claude Code, Cursor, Codex, anywhere — copy the result
// into ANY chat.

/**
 * Arguments for {@link contextPack}.
 */
export interface ContextPackArgs {
  /** Topic / question to gather context for. */
  query: string;
  /** Approximate token budget for the bundle. ~4 chars/token assumption. Default 4000. */
  budget_tokens?: number;
  /** Restrict retrieval to this folder. */
  folder?: string;
  /** Include backlinks of top-K notes (1-line each)? Default true. */
  include_backlinks?: boolean;
  /** Include the last N daily notes? Default 0 (off). Set to 3 for "what was I doing recently". */
  recent_dailies?: number;
}

/**
 * Result of {@link contextPack} — a ready-to-paste markdown bundle.
 *
 * `bundle` is the full markdown blob the caller can paste into any AI
 * chat. `sections` exposes per-section byte counts so the agent can
 * report what's inside ("4 notes + 12 backlinks + 3 daily notes").
 * `estimated_tokens` uses the ~4 chars/token heuristic for English /
 * Cyrillic; CJK estimates run higher (real tokenization is
 * model-dependent).
 */
export interface ContextPackResult {
  /** Echo of the input query. */
  query: string;
  /** The packed markdown bundle ready to paste into an AI chat. */
  bundle: string;
  /** Approximate token count (chars / 4). */
  estimated_tokens: number;
  /** Echo of the input budget. */
  budget_tokens: number;
  /** Per-section byte counts for observability. */
  sections: {
    notes: number;
    backlinks: number;
    dailies: number;
  };
  /** Top-K hit paths included in the bundle. */
  included_notes: string[];
}

/**
 * Token-budgeted vault context export — runs hybrid retrieval, gathers
 * note bodies + backlinks + recent dailies, packs to a token budget,
 * returns one ready-to-paste markdown blob.
 *
 * The MCP-native answer to Smart Connections' "Send to Smart Context"
 * pattern, but works in any chat (Claude / Cursor / Codex / web UI) by
 * producing a plain-text bundle. Saves the agent from orchestrating 5
 * separate tool calls.
 *
 * Budget enforcement: each note's body is truncated to ~50% of remaining
 * budget so room remains for backlinks + dailies; oversize bodies get a
 * `[…truncated…]` marker. As a final defense-in-depth, the assembled bundle
 * is hard-capped at `budget_tokens × 4` chars and marked `[…budget cap
 * reached…]` if truncated. Top-3 included notes get 1-line backlink
 * summaries when `include_backlinks` is true.
 *
 * @param vault - The vault.
 * @param args - {@link ContextPackArgs}. `query` required + non-empty.
 * @param ctx - Server-side context: `ftsIndex` (nullable) and `embedFile`
 *   (path may not exist) — same shape as {@link searchHybrid}.
 * @returns A {@link ContextPackResult} with the packed bundle + meta.
 * @throws {Error} If `query` is empty / whitespace-only.
 * @example
 * ```ts
 * const pack = await contextPack(
 *   vault,
 *   {
 *     query: "How do I tune the hybrid retrieval?",
 *     budget_tokens: 3000,
 *     include_backlinks: true,
 *     recent_dailies: 3
 *   },
 *   { ftsIndex, embedFile: "/path/to/vault.embed.db" }
 * );
 * console.log(pack.bundle); // ready to paste into any chat
 * ```
 */
export async function contextPack(
  vault: Vault,
  args: ContextPackArgs,
  ctx: { ftsIndex: FtsIndex | null; embedFile: string }
): Promise<ContextPackResult> {
  await vault.ensureExists();
  if (!args.query?.trim()) throw new Error("context_pack: `query` is required");
  const budget = args.budget_tokens ?? 4000;
  const charBudget = budget * 4; // ~4 chars/token
  const includeBacklinks = args.include_backlinks !== false;
  const recentN = Math.max(0, args.recent_dailies ?? 0);

  // 1) Hybrid retrieval — top-K notes
  const search = await searchHybrid(
    vault,
    { query: args.query, folder: args.folder, limit: 10 },
    { ftsIndex: ctx.ftsIndex, embedFile: ctx.embedFile }
  );

  const sections: string[] = [`# Context for: ${args.query}\n`];
  const includedNotes: string[] = [];
  let charsUsed = sections[0]?.length ?? 0;
  let notesBytes = 0;
  let backlinksBytes = 0;
  let dailiesBytes = 0;

  // 2) Pack note bodies until budget exhausted
  sections.push("## Top notes");
  for (const m of search.matches) {
    if (charsUsed >= charBudget) break;
    try {
      const note = await vault.readNote(vault.resolveInside(m.path), undefined);
      const body = note.parsed.body.trim();
      const headerLen = m.path.length + 5;
      const remaining = charBudget - charsUsed;
      // Truncate body to fit remaining budget for THIS note (~50% of remainder
      // so we leave room for backlinks + dailies).
      const noteCap = Math.min(body.length, Math.max(500, Math.floor(remaining * 0.5)));
      const trimmed = body.length <= noteCap ? body : `${body.slice(0, noteCap)}\n\n[…truncated…]`;
      const block = `### ${m.path}\n\n${trimmed}\n`;
      sections.push(block);
      charsUsed += block.length + headerLen;
      notesBytes += block.length;
      includedNotes.push(m.path);
    } catch {
      // skip unreadable notes
    }
  }

  // 3) 1-line backlink summaries for top-3
  if (includeBacklinks && includedNotes.length > 0 && charsUsed < charBudget) {
    sections.push("## Backlinks");
    let backlinksAdded = 0;
    for (const notePath of includedNotes.slice(0, 3)) {
      if (charsUsed >= charBudget) break;
      try {
        const links = await getBacklinks(vault, { path: notePath, limit: 5 });
        if (links.length > 0) {
          const block = `### → ${notePath}\n${links.map((l) => `- ${l.path} : ${(l.snippets[0] ?? "").slice(0, 80)}`).join("\n")}\n`;
          sections.push(block);
          charsUsed += block.length;
          backlinksBytes += block.length;
          backlinksAdded += links.length;
        }
      } catch {
        // skip
      }
    }
    if (backlinksAdded === 0) sections.pop(); // remove empty heading
  }

  // 4) Recent daily notes
  if (recentN > 0 && charsUsed < charBudget) {
    try {
      const recent = await getRecentEdits(vault, { since_minutes: 60 * 24 * 7, limit: recentN, folder: args.folder });
      const dailies = recent.filter((r) => /\d{4}-\d{2}-\d{2}/.test(r.path));
      if (dailies.length > 0) {
        sections.push(`## Recent (${dailies.length} dailies, last 7 days)`);
        for (const d of dailies) {
          if (charsUsed >= charBudget) break;
          const block = `- ${d.path} (${d.mtime})`;
          sections.push(block);
          charsUsed += block.length;
          dailiesBytes += block.length;
        }
      }
    } catch {
      // skip
    }
  }

  // v3.8.0-rc.6 R-4 — hard budget cap. The per-section checks above leave
  // small gaps (section headers like "## Top notes" aren't tracked, and
  // join("\n") overhead accumulates). Slice as defense-in-depth so the
  // returned bundle can never exceed the token budget regardless of those
  // small systematic underestimates.
  const raw = sections.join("\n");
  const bundle = raw.length > charBudget ? `${raw.slice(0, charBudget)}\n[…budget cap reached…]` : raw;
  return {
    query: args.query,
    bundle,
    estimated_tokens: Math.ceil(bundle.length / 4),
    budget_tokens: budget,
    sections: { notes: notesBytes, backlinks: backlinksBytes, dailies: dailiesBytes },
    included_notes: includedNotes
  };
}

// ─── small set / string helpers shared by find_similar / get_note_neighbors ─

/**
 * Compute the Jaccard similarity coefficient of two sets — `|A ∩ B| / |A ∪ B|`.
 *
 * Returns 0 for two empty sets (mathematically undefined; this convention
 * avoids NaN propagation in similarity score sums). Used by
 * {@link findSimilar}, {@link getNoteNeighbors}, and {@link searchHybrid}
 * for tag and co-backlink overlap computations.
 *
 * @internal
 * @param a - First set.
 * @param b - Second set.
 * @returns Similarity in `[0, 1]`. 1 means identical sets; 0 means disjoint
 *   or both empty.
 * @example
 * ```ts
 * jaccard(new Set([1, 2, 3]), new Set([2, 3, 4]));
 * // → 0.5  (intersection {2,3} over union {1,2,3,4})
 * ```
 */
export function jaccard<T>(a: Set<T>, b: Set<T>): number {
  if (a.size === 0 && b.size === 0) return 0;
  let inter = 0;
  for (const x of a) if (b.has(x)) inter += 1;
  const union = a.size + b.size - inter;
  return union === 0 ? 0 : inter / union;
}

/**
 * Count elements present in both sets — `|A ∩ B|`.
 *
 * Cheaper than `new Set([...a].filter((x) => b.has(x))).size` when only
 * the count is needed (no intermediate set allocation). Used by
 * {@link findSimilar} for shared-outbound overlap percentage.
 *
 * @internal
 * @param a - First set.
 * @param b - Second set.
 * @returns Non-negative integer count of common elements.
 * @example
 * ```ts
 * intersectionSize(new Set([1, 2, 3]), new Set([2, 3, 4]));
 * // → 2
 * ```
 */
export function intersectionSize<T>(a: Set<T>, b: Set<T>): number {
  let n = 0;
  for (const x of a) if (b.has(x)) n += 1;
  return n;
}

/**
 * Build the character n-gram set of a string.
 *
 * Used by {@link findSimilar} for title 3-gram Jaccard similarity. Strings
 * shorter than `n` produce a single-element set containing the string
 * itself (so a 1-char title doesn't collapse to an empty signal).
 *
 * @internal
 * @param s - Input string.
 * @param n - N-gram length (typically 3 for title matching).
 * @returns Set of character n-grams. Empty only when `s` is empty.
 * @example
 * ```ts
 * ngrams("hello", 3);
 * // → Set { "hel", "ell", "llo" }
 * ngrams("a", 3);
 * // → Set { "a" }
 * ```
 */
export function ngrams(s: string, n: number): Set<string> {
  const out = new Set<string>();
  if (s.length < n) {
    if (s) out.add(s);
    return out;
  }
  for (let i = 0; i <= s.length - n; i++) out.add(s.slice(i, i + n));
  return out;
}

// Per-entries-array memo for the lookup indices findBestMatch needs. Keyed by
// the entries array reference so a fresh listMarkdown() result rebuilds the
// indices, but a hot loop calling findBestMatch repeatedly with the same
// `entries` argument shares one index. Closes the v1.2 bench finding that
// findBestMatch was the dominant cost in find_similar / get_note_neighbors /
// vault_stats / rename_note at 10k vaults (~2-3s p50 → ~50-200ms post-fix).
interface EntryIndex {
  byBasename: Map<string, FileEntry[]>;
  byRelPath: Map<string, FileEntry>;
}
const entryIndexCache = new WeakMap<FileEntry[], EntryIndex>();

/**
 * Build (or fetch from per-`entries`-array cache) the basename / relPath
 * lookup indices that `findBestMatch` needs.
 *
 * Cached via `WeakMap<FileEntry[], EntryIndex>` keyed by the entries array
 * reference: a fresh `listMarkdown()` result rebuilds the indices, but a
 * hot loop calling `findBestMatch` repeatedly with the same `entries`
 * argument shares one index. Closes the v1.2 bench finding that
 * `findBestMatch` was the dominant cost on 10k-note vaults
 * (~2-3s p50 → ~50-200ms post-fix).
 *
 * @internal
 * @param entries - File-entry array from `vault.listMarkdown()`.
 * @returns `{ byBasename, byRelPath }` — basename → entries (multi-value
 *   on collisions); relPath → entry (unique).
 */
export function indexFor(entries: FileEntry[]): EntryIndex {
  const cached = entryIndexCache.get(entries);
  if (cached) return cached;
  const byBasename = new Map<string, FileEntry[]>();
  const byRelPath = new Map<string, FileEntry>();
  for (const e of entries) {
    const key = stripMd(e.basename).toLowerCase();
    const slot = byBasename.get(key);
    if (slot) slot.push(e);
    else byBasename.set(key, [e]);
    byRelPath.set(stripMd(e.relPath).toLowerCase(), e);
  }
  const idx: EntryIndex = { byBasename, byRelPath };
  entryIndexCache.set(entries, idx);
  return idx;
}

/**
 * Resolve a wikilink target string to a concrete vault entry — the
 * Obsidian-style fuzzy matcher used by every link-traversal tool.
 *
 * Resolution priority:
 * 1. Relative paths (`./`, `../`) → resolve via `path.posix.normalize`
 *    against `fromNote`'s directory.
 * 2. Exact basename match. On collision, prefer the entry in `fromNote`'s
 *    directory; otherwise return the first.
 * 3. Path-qualified target → exact-relPath match, then `endsWith` fallback.
 *
 * Matches Obsidian's client-side link resolution closely enough that
 * agents and the desktop app agree on what `[[Target]]` points at.
 *
 * @internal
 * @param entries - File-entry array from `vault.listMarkdown()`.
 * @param target - Link target string (with or without `.md`).
 * @param fromNote - Source note's vault-relative path (biases resolution
 *   toward same folder; required for `./` / `../`).
 * @returns The resolved `FileEntry`, or null if no match.
 * @example
 * ```ts
 * const entries = await vault.listMarkdown();
 * findBestMatch(entries, "Foo");              // bare basename
 * findBestMatch(entries, "Sub/Foo", "X.md");  // path-qualified
 * findBestMatch(entries, "./Sibling", "Sub/X.md");  // relative
 * ```
 */
export function findBestMatch(entries: FileEntry[], target: string, fromNote?: string): FileEntry | null {
  const idx = indexFor(entries);

  if (target.startsWith("./") || target.startsWith("../") || target.includes("/../")) {
    if (fromNote) {
      const fromDir = path.dirname(fromNote);
      const joined = path.posix.normalize(path.posix.join(fromDir.split(path.sep).join("/"), target));
      const lower = stripMd(joined).toLowerCase();
      const rel = idx.byRelPath.get(lower);
      if (rel) return rel;
    }
  }
  const norm = stripMd(target).toLowerCase();
  const exact = idx.byBasename.get(norm) ?? [];
  if (exact.length === 1) return exact[0] ?? null;
  if (exact.length > 1 && fromNote) {
    const fromDir = path.dirname(fromNote);
    const sameDir = exact.find((e) => path.dirname(e.relPath) === fromDir);
    if (sameDir) return sameDir;
  }
  if (exact.length > 0) return exact[0] ?? null;
  if (target.includes("/")) {
    const lower = stripMd(target).toLowerCase();
    const path1 = idx.byRelPath.get(lower);
    if (path1) return path1;
    // endsWith match — falls back to a scan, but only for path-qualified
    // targets that don't exact-match (rare).
    for (const e of entries) {
      if (stripMd(e.relPath).toLowerCase().endsWith(`/${lower}`)) return e;
    }
  }
  return null;
}

/**
 * Strip a trailing `.md` extension (case-insensitive) from a filename or
 * basename.
 *
 * @internal
 * @param name - Filename or basename.
 * @returns The input with any trailing `.md` removed.
 * @example
 * ```ts
 * stripMd("Foo.md");   // → "Foo"
 * stripMd("Foo.MD");   // → "Foo"
 * stripMd("Foo.canvas"); // → "Foo.canvas" (only .md is stripped)
 * ```
 */
export function stripMd(name: string): string {
  return name.replace(/\.md$/i, "");
}

/**
 * Normalize a tag for comparison — strip leading `#` characters and
 * lowercase the rest.
 *
 * Used everywhere the toolkit compares tags ({@link listNotes},
 * {@link listTags}, {@link findSimilar}) to keep `#Draft` / `Draft` /
 * `#draft` / `DRAFT` all hashing to the same key.
 *
 * @internal
 * @param t - Tag string (with or without leading `#`).
 * @returns Lowercased, `#`-stripped tag.
 * @example
 * ```ts
 * normalizeTag("#Draft");   // → "draft"
 * normalizeTag("DRAFT");    // → "draft"
 * normalizeTag("##Idea");   // → "idea"
 * ```
 */
export function normalizeTag(t: string): string {
  return t.replace(/^#+/, "").toLowerCase();
}
