import Database from "better-sqlite3"
import {
  afterAll,
  beforeAll,
  beforeEach,
  describe,
  expect,
  it,
  vi,
} from "vitest"

// Mock @enesis/editor — its dependency chain pulls in Vue files that vitest
// can't parse without a Vue plugin.
let blockIdSeed = 0
vi.mock("@enesis/editor", () => ({
  splitMarkdownIntoBlocks: (content: string) =>
    content
      .split("\n")
      .filter(Boolean)
      .map((line) => ({
        id: `test-block-${++blockIdSeed}`,
        content: line,
        depth: 0,
      })),
}))

// Mock @tauri-apps/plugin-sql — wrap better-sqlite3 so the indexer runs
// against a real in-memory SQLite database during tests.
//
// The real plugin uses $1, $2 positional placeholders, but better-sqlite3
// uses ?. We translate $N → ? before preparing statements.
vi.mock("@tauri-apps/plugin-sql", () => {
  function translate(sql: string): string {
    return sql.replace(/\$\d+/g, "?")
  }

  class MockDatabase {
    private db: Database.Database

    private constructor(db: Database.Database) {
      this.db = db
    }

    static async load(_connectionString: string): Promise<MockDatabase> {
      const { default: BS3 } = await import("better-sqlite3")
      return new MockDatabase(new BS3(":memory:"))
    }

    async execute(
      sql: string,
      params?: unknown[],
    ): Promise<{ rowsAffected: number }> {
      if (params) {
        const stmt = this.db.prepare(translate(sql))
        const result = stmt.run(...params)
        return { rowsAffected: result.changes }
      }
      const result = this.db.exec(sql)
      return { rowsAffected: result.changes ?? 0 }
    }

    async select<T>(sql: string, params?: unknown[]): Promise<T[]> {
      const stmt = this.db.prepare(translate(sql))
      return (params ? stmt.all(...params) : stmt.all()) as T[]
    }
  }

  return { default: MockDatabase }
})

// Now it's safe to import the real indexer
import { fullReindex, initIndex, syncIndex } from "./indexer"

let db: Awaited<ReturnType<typeof initIndex>>

beforeAll(async () => {
  db = await initIndex(":memory:")
})

afterAll(() => {
  vi.restoreAllMocks()
})

function opts(files: Record<string, string>, workspacePath = "/ws") {
  return {
    listDirectory: vi.fn(async () =>
      Object.keys(files).map((f) => `${workspacePath}/${f}`),
    ),
    readFile: vi.fn(async (path: string) => {
      const relative = path.replace(`${workspacePath}/`, "")
      if (!(relative in files)) throw new Error(`ENOENT: ${path}`)
      return files[relative]
    }),
    workspacePath,
  }
}

describe("syncIndex", () => {
  // Each test starts with a clean database so state doesn't leak between them
  beforeEach(async () => {
    await db.execute("DELETE FROM pages")
  })

  it("indexes new files", async () => {
    const o = opts({ "pages/New.md": "# Hello World" })
    await syncIndex(o, db)

    const pages = await db.select<{ path: string; title: string }[]>(
      "SELECT path, title FROM pages",
    )
    expect(pages).toEqual([{ path: "pages/New.md", title: "Hello World" }])
  })

  it("skips unchanged files", async () => {
    const o = opts({ "pages/Skip.md": "same content" })
    await syncIndex(o, db)

    await syncIndex(o, db)

    const pageRows = await db.select<{ path: string }[]>(
      "SELECT path FROM pages",
    )
    expect(pageRows).toHaveLength(1)
    expect(pageRows[0].path).toBe("pages/Skip.md")
  })

  it("re-indexes changed files", async () => {
    const o = opts({ "pages/Changed.md": "version 1" })
    await syncIndex(o, db)

    o.readFile = vi.fn(async () => "version 2")
    await syncIndex(o, db)

    const blocks = await db.select<{ content: string }[]>(
      "SELECT content FROM blocks",
    )
    expect(blocks).toEqual([{ content: "version 2" }])
  })

  it("removes files deleted from disk", async () => {
    const o = opts({
      "pages/Keep.md": "keep me",
      "pages/Remove.md": "remove me",
    })
    await syncIndex(o, db)

    const updated = opts({ "pages/Keep.md": "keep me" })
    await syncIndex(updated, db)

    const pages = await db.select<{ path: string }[]>(
      "SELECT path FROM pages ORDER BY path",
    )
    expect(pages).toEqual([{ path: "pages/Keep.md" }])
  })

  it("ignores non-.md files", async () => {
    const o = opts({
      "page.txt": "not markdown",
      "readme.md": "# Actual doc",
    })
    await syncIndex(o, db)

    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
    expect(pages).toEqual([{ path: "readme.md" }])
  })

  it("continues on read error", async () => {
    const o = opts({ "pages/Good.md": "fine" })
    vi.mocked(o.listDirectory).mockResolvedValue([
      "/ws/pages/Bad.md",
      "/ws/pages/Good.md",
    ])
    vi.mocked(o.readFile).mockImplementation(async (path: string) => {
      if (path.includes("Bad.md")) throw new Error("read error")
      return "fine"
    })

    await syncIndex(o, db)

    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
    expect(pages).toEqual([{ path: "pages/Good.md" }])
  })
})

describe("fullReindex", () => {
  it("wipes and rebuilds all pages", async () => {
    await db.execute(
      "INSERT INTO pages (path, title, modified_at, word_count) VALUES ($1, $2, $3, $4)",
      ["pages/Stale.md", "Stale", 1000, 2],
    )

    const o = opts({ "pages/Fresh.md": "# Fresh Start" })
    await fullReindex(o, db)

    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
    expect(pages).toEqual([{ path: "pages/Fresh.md" }])
  })

  it("continues on read error", async () => {
    const o = opts({ "pages/Good.md": "fine" })
    vi.mocked(o.listDirectory).mockResolvedValue([
      "/ws/pages/Bad.md",
      "/ws/pages/Good.md",
    ])
    vi.mocked(o.readFile).mockImplementation(async (path: string) => {
      if (path.includes("Bad.md")) throw new Error("read error")
      return "fine"
    })

    await fullReindex(o, db)

    const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
    expect(pages).toEqual([{ path: "pages/Good.md" }])
  })
})

describe("migrateContentHash via initIndex", () => {
  // This is the one integration-style test in the suite: it creates a real DB
  // file on disk with the old schema, opens it through initIndex (which runs
  // migrateContentHash), and verifies the column was added. Writing a temp
  // file is unavoidable because initIndex opens the DB via a path string.
  // If the test process is killed mid-run, the temp dir leaks — acceptable
  // for a dev-only test.
  it("adds content_hash column to existing old-schema database", async () => {
    // Build the old schema manually (without content_hash)
    const raw = new Database(":memory:")
    raw.exec(`
      CREATE TABLE pages (
        path TEXT PRIMARY KEY,
        title TEXT NOT NULL DEFAULT '',
        modified_at INTEGER NOT NULL,
        word_count INTEGER NOT NULL DEFAULT 0
      );
    `)

    // Verify old schema has no content_hash
    const before = raw
      .prepare(
        "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
      )
      .all()
    expect(before).toHaveLength(0)

    // Serialize to a temp file and open through initIndex
    const { writeFileSync, mkdtempSync } = await import("node:fs")
    const { join } = await import("node:path")
    const { tmpdir } = await import("node:os")
    const dir = mkdtempSync(join(tmpdir(), "indexer-test-"))
    const dbPath = join(dir, "index.db")
    writeFileSync(dbPath, raw.serialize())

    const migrated = await initIndex(dbPath)
    const after = await migrated.select<{ name: string }[]>(
      "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
    )
    expect(after).toHaveLength(1)
    expect(after[0].name).toBe("content_hash")

    // Cleanup
    const { rmSync } = await import("node:fs")
    rmSync(dir, { recursive: true })
  })
})
