import { describe, expect, it, vi } from "vitest";
import { TFile, type App, type EventRef } from "obsidian";

import { VaultNoteCandidateProvider } from "../../../../src/features/chat/panel/vault-note-candidate-provider";

describe("VaultNoteCandidateProvider", () => {
  it("builds note candidates from markdown files", () => {
    const app = appFixture({
      files: [
        { basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } },
        { basename: "Beta", path: "Beta.md", stat: { mtime: 200 } },
        { basename: "Daily", path: "Personal/Daily Notes/Daily.md", stat: { mtime: 300 } },
        { basename: "Projects", path: "Bases/Projects.base", stat: { mtime: 400 } },
        { basename: "Paper", path: "References/Paper.pdf", stat: { mtime: 500 } },
        { basename: "Diagram", path: "Assets/Diagram.png", stat: { mtime: 600 } },
        { basename: "LICENSE", path: "Attachments/LICENSE", stat: { mtime: 700 } },
      ],
      lastOpenFiles: ["Beta.md"],
      linktexts: new Map([
        ["notes/Alpha.md", "Alpha"],
        ["Personal/Daily Notes/Daily.md", "Personal/Daily Notes/Daily"],
        ["Bases/Projects.base", "Bases/Projects"],
        ["References/Paper.pdf", "References/Paper"],
        ["Assets/Diagram.png", "Assets/Diagram.png"],
        ["Attachments/LICENSE", "Attachments/LICENSE"],
      ]),
      headings: new Map([["Beta.md", [{ heading: "Overview", level: 1 }]]]),
    });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.candidates("Inbox.md")).toEqual([
      { basename: "Alpha", displayName: "Alpha", path: "notes/Alpha.md", mtime: 100, linktext: "Alpha", headings: [], recentIndex: null },
      {
        basename: "Beta",
        displayName: "Beta",
        path: "Beta.md",
        mtime: 200,
        linktext: "Beta",
        headings: [{ heading: "Overview", linkHeading: "Overview", level: 1 }],
        recentIndex: 0,
      },
      {
        basename: "Daily",
        displayName: "Daily",
        path: "Personal/Daily Notes/Daily.md",
        mtime: 300,
        linktext: "Personal/Daily Notes/Daily",
        headings: [],
        recentIndex: null,
      },
      {
        basename: "Projects",
        displayName: "Projects.base",
        path: "Bases/Projects.base",
        mtime: 400,
        linktext: "Bases/Projects.base",
        headings: [],
        recentIndex: null,
      },
      {
        basename: "Paper",
        displayName: "Paper.pdf",
        path: "References/Paper.pdf",
        mtime: 500,
        linktext: "References/Paper.pdf",
        headings: [],
        recentIndex: null,
      },
      {
        basename: "Diagram",
        displayName: "Diagram.png",
        path: "Assets/Diagram.png",
        mtime: 600,
        linktext: "Assets/Diagram.png",
        headings: [],
        recentIndex: null,
      },
      {
        basename: "LICENSE",
        displayName: "LICENSE",
        path: "Attachments/LICENSE",
        mtime: 700,
        linktext: "Attachments/LICENSE",
        headings: [],
        recentIndex: null,
      },
    ]);
  });

  it("caches file candidates and reprojects linktext by source path", () => {
    const files = [{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } }];
    const getFiles = vi.fn(() => vaultFiles(files));
    const fileToLinktext = vi.fn((file: TFile, sourcePath: string) => `${sourcePath}:${file.basename}`);
    const app = appFixture({ getFiles, fileToLinktext });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.candidates("Daily/Today.md")[0]?.linktext).toBe("Daily/Today.md:Alpha");
    expect(provider.candidates("Projects.md")[0]?.linktext).toBe("Projects.md:Alpha");
    expect(provider.candidates("Daily/Today.md")[0]?.linktext).toBe("Daily/Today.md:Alpha");
    expect(getFiles).toHaveBeenCalledOnce();
    expect(fileToLinktext).toHaveBeenCalledTimes(2);
  });

  it("invalidates cached candidates when the vault changes", () => {
    const files = [{ basename: "Alpha", path: "notes/Alpha.md", stat: { mtime: 100 } }];
    const app = appFixture({ files });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.candidates("Inbox.md").map((candidate) => candidate.basename)).toEqual(["Alpha"]);

    files.push({ basename: "Beta", path: "notes/Beta.md", stat: { mtime: 200 } });
    app.triggerVaultEvent("create");

    expect(provider.candidates("Inbox.md").map((candidate) => candidate.basename)).toEqual(["Alpha", "Beta"]);
  });

  it("unregisters Obsidian events when disposed", () => {
    const app = appFixture();
    const provider = new VaultNoteCandidateProvider(app);

    provider.dispose();

    expect(app.offref).toHaveBeenCalledTimes(6);
  });

  it("resolves wikilinks through metadata cache before direct path fallback", () => {
    const linked = tFile("notes/Alpha.md", "Alpha");
    const direct = tFile("Alpha.md", "Alpha direct");
    const app = appFixture({
      linkDestination: linked,
      abstractFiles: new Map([["Alpha.md", direct]]),
    });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.resolveMention("Alpha", "Inbox.md")).toEqual({ name: "Alpha", path: "notes/Alpha.md" });
  });

  it("resolves direct markdown paths when metadata has no match", () => {
    const direct = tFile("notes/Alpha.md", "Alpha");
    const app = appFixture({
      abstractFiles: new Map([["notes/Alpha.md", direct]]),
    });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.resolveMention("notes/Alpha", "")).toEqual({ name: "Alpha", path: "notes/Alpha.md" });
    expect(provider.resolveMention("Missing", "")).toBeNull();
  });

  it("uses the active note only as Obsidian link-resolution context", () => {
    const linked = tFile("notes/Project.md", "Project");
    const getFirstLinkpathDest = vi.fn(() => linked);
    const app = appFixture({
      getFirstLinkpathDest,
    });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.resolveMention("Project", "Daily/Today.md")).toEqual({ name: "Project", path: "notes/Project.md" });
    expect(getFirstLinkpathDest).toHaveBeenCalledWith("Project", "Daily/Today.md");
  });

  it("resolves non-markdown wikilinks through Obsidian metadata", () => {
    const linked = tFile("Bases/Projects.base", "Projects");
    const getFirstLinkpathDest = vi.fn(() => linked);
    const app = appFixture({
      getFirstLinkpathDest,
    });
    const provider = new VaultNoteCandidateProvider(app);

    expect(provider.resolveMention("Bases/Projects.base", "Daily/Today.md")).toEqual({ name: "Projects", path: "Bases/Projects.base" });
    expect(getFirstLinkpathDest).toHaveBeenCalledWith("Bases/Projects.base", "Daily/Today.md");
  });
});

interface AppFixture extends App {
  offref: ReturnType<typeof vi.fn>;
  triggerVaultEvent(name: string): void;
}

function appFixture(
  options: {
    linkDestination?: TFile | null;
    getFirstLinkpathDest?: (target: string, sourcePath: string) => TFile | null;
    lastOpenFiles?: string[];
    files?: { basename: string; path: string; stat: { mtime: number } }[];
    getFiles?: () => TFile[];
    abstractFiles?: Map<string, TFile>;
    linktexts?: Map<string, string>;
    fileToLinktext?: (file: TFile, sourcePath: string, omitMdExtension?: boolean) => string;
    headings?: Map<string, { heading: string; level: number }[]>;
  } = {},
): AppFixture {
  const refs: { source: "vault" | "metadata"; name: string; callback: () => void; ref: EventRef }[] = [];
  const offref = vi.fn((ref: EventRef) => {
    const index = refs.findIndex((event) => event.ref === ref);
    if (index !== -1) refs.splice(index, 1);
  });
  const on =
    (source: "vault" | "metadata") =>
    (name: string, callback: () => void): EventRef => {
      const ref = { id: `${source}:${name}:${refs.length.toString()}` } as unknown as EventRef;
      refs.push({ source, name, callback, ref });
      return ref;
    };
  return {
    offref,
    triggerVaultEvent: (name: string) => {
      for (const event of refs.filter((ref) => ref.source === "vault" && ref.name === name)) {
        event.callback();
      }
    },
    workspace: {
      getLastOpenFiles: () => options.lastOpenFiles ?? [],
    },
    metadataCache: {
      on: on("metadata"),
      offref,
      getFirstLinkpathDest: options.getFirstLinkpathDest ?? (() => options.linkDestination ?? null),
      fileToLinktext:
        options.fileToLinktext ??
        ((file: TFile, _sourcePath: string, omitMdExtension?: boolean) =>
          options.linktexts?.get(file.path) ?? (omitMdExtension === true ? file.path.replace(/\.md$/i, "") : file.path)),
      getFileCache: (file: TFile) => ({ headings: options.headings?.get(file.path) ?? [] }),
    },
    vault: {
      on: on("vault"),
      offref,
      getFiles: options.getFiles ?? (() => vaultFiles(options.files ?? [])),
      getMarkdownFiles: () => vaultFiles(options.files ?? []).filter((file) => file.path.toLowerCase().endsWith(".md")),
      getAbstractFileByPath: (path: string) => options.abstractFiles?.get(path) ?? null,
    },
  } as unknown as AppFixture;
}

function tFile(path: string, basename: string): TFile {
  const name = path.split("/").pop() ?? path;
  const extensionStart = name.lastIndexOf(".");
  const extension = extensionStart === -1 ? "" : name.slice(extensionStart + 1);
  return Object.assign(new TFile(), { path, basename, extension, name });
}

function vaultFiles(files: { basename: string; path: string; stat: { mtime: number } }[]): TFile[] {
  return files.map((file) => Object.assign(tFile(file.path, file.basename), { stat: file.stat }));
}
