/**
 * Workspace indexer.
 * Walks markdown files in the workspace, parses them into blocks using
 * splitMarkdownIntoBlocks, and populates the SQLite index (pages, blocks, links, FTS5).
 *
 * The indexer is read-only — it never writes to markdown files. Blocks without
 * id:: stamps get transient index-time IDs that are regenerated on re-index.
 * Stamping (writing id:: to the file) happens in D2 when references are created.
 *
 * Content hash diffing skips blocks whose content hasn't changed, keeping
 * incremental re-index fast and preserving rowids for in-memory references.
 */

import { splitMarkdownIntoBlocks } from "@enesis/editor"
import Database from "@tauri-apps/plugin-sql"
import {
  computePageTitle,
  contentHash,
  detectBlockType,
  extractLinks,
  extractProperties,
  extractTaskState,
  toRelativePath,
} from "./parse"
import { SCHEMA_SQL } from "./schema"

export interface IndexerOptions {
  /** Tauri invoke wrapper for listing directory contents. */
  listDirectory: (path: string) => Promise<string[]>
  /** Tauri invoke wrapper for reading file contents. */
  readFile: (path: string) => Promise<string>
  /** Absolute path to the workspace root directory. */
  workspacePath: string
}

/**
 * Initialize the SQLite index at the given path.
 * Creates the database file if it doesn't exist, runs CREATE TABLE IF NOT EXISTS,
 * and applies any schema migrations needed for existing databases.
 * Tables, indices, FTS5 virtual table, and sync triggers are all idempotent.
 */
export async function initIndex(dbPath: string): Promise<Database> {
  const db = await Database.load(`sqlite:${dbPath}`)
  await db.execute(SCHEMA_SQL)
  await migrateContentHash(db)
  await migrateEmbeddingHash(db)
  await migratePropertiesTable(db)
  return db
}

/**
 * Add the `content_hash` column to `pages` if it doesn't exist yet.
 * SQLite doesn't support `ADD COLUMN IF NOT EXISTS`, so we query PRAGMA
 * table_info first and only ALTER when the column is absent.
 */
async function migrateContentHash(db: Database): Promise<void> {
  const cols = await db.select<{ name: string }[]>(
    "SELECT name FROM pragma_table_info('pages')",
  )
  if (!cols.some((c) => c.name === "content_hash")) {
    await db.execute(
      "ALTER TABLE pages ADD COLUMN content_hash TEXT NOT NULL DEFAULT ''",
    )
  }
}

async function migrateEmbeddingHash(db: Database): Promise<void> {
  const cols = await db.select<{ name: string }[]>(
    "SELECT name FROM pragma_table_info('blocks')",
  )
  if (!cols.some((c) => c.name === "embedding_hash")) {
    await db.execute(
      "ALTER TABLE blocks ADD COLUMN embedding_hash TEXT NOT NULL DEFAULT ''",
    )
  }
  if (!cols.some((c) => c.name === "embedding")) {
    await db.execute("ALTER TABLE blocks ADD COLUMN embedding BLOB")
  }
}

async function migratePropertiesTable(db: Database): Promise<void> {
  const tables = await db.select<{ name: string }[]>(
    "SELECT name FROM sqlite_master WHERE type='table' AND name='properties'",
  )
  if (tables.length === 0) {
    await db.execute(
      `CREATE TABLE properties (
        block_id TEXT NOT NULL REFERENCES blocks(id) ON DELETE CASCADE,
        page_path TEXT NOT NULL REFERENCES pages(path) ON DELETE CASCADE,
        key TEXT NOT NULL,
        value TEXT NOT NULL,
        PRIMARY KEY (block_id, key)
      )`,
    )
    await db.execute(
      "CREATE INDEX IF NOT EXISTS idx_properties_key ON properties(key, value)",
    )
    await db.execute(
      "CREATE INDEX IF NOT EXISTS idx_properties_page ON properties(page_path, key)",
    )
    await db.execute(
      "CREATE INDEX IF NOT EXISTS idx_properties_block ON properties(block_id)",
    )
  }
}

/**
 * Index a single markdown file into the database.
 *
 * - Upserts the page row (title, word count, modified_at)
 * - Diffs existing block hashes against new content; skips unchanged blocks
 * - Upserts changed or new blocks
 * - Re-indexes links (delete-all + re-insert within each changed block)
 * - Removes blocks that no longer exist in the file
 *
 * The page **title** is always non-empty: `extractTitle` returns the first
 * `# Heading 1` in the content, falling back to the filename stem (without
 * `.md` extension) when there's no heading. This guarantees downstream queries
 * like `WHERE title = 'New Page'` from `[[New Page]]` links resolve correctly
 * regardless of whether the page has a heading yet.
 *
 * @param db — Open SQLite database connection
 * @param filePath — Path relative to workspace root, e.g. `"pages/Architecture.md"`
 * @param content — Raw markdown file content
 *
 * @remarks Edge cases & deferred work:
 * - **Existing workspaces** that have pages with empty titles (indexed before
 *   the `computePageTitle` fallback was added) won't get fixed until the
 *   next `fullReindex` (app restart) or incremental re-index (file change).
 *   A one-time migration (`UPDATE pages SET title = filename_stem WHERE
 *   title = ''`) would fix this proactively.
 * - `modified_at` is set to `Date.now() / 1000` at index time, NOT the file's
 *   mtime from `fs::metadata`. This means the same file indexed twice in
 *   quick succession gets the same timestamp, making `ORDER BY modified_at`
 *   unreliable for ordering blocks by actual edit time.
 * - **Block ID stamping** (`has_id_stamp`) is tracked but stamp creation
 *   (writing `id:: <nanoid>` to the file on reference creation) is deferred
 *   to D2. The indexer is read-only and never modifies files.
 */
export async function indexFile(
  db: Database,
  filePath: string,
  content: string,
): Promise<void> {
  const now = Math.floor(Date.now() / 1000)
  const hash = contentHash(content)
  const title = computePageTitle(content, filePath)

  await db.execute(
    `INSERT INTO pages (path, title, modified_at, word_count, content_hash)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT(path) DO UPDATE SET
       title = excluded.title,
       modified_at = excluded.modified_at,
       word_count = excluded.word_count,
       content_hash = excluded.content_hash`,
    [filePath, title, now, content.split(/\s+/).length, hash],
  )

  const existing = await db.select<{ id: string; content_hash: string }[]>(
    "SELECT id, content_hash FROM blocks WHERE page_path = $1",
    [filePath],
  )
  const existingMap = new Map(existing.map((b) => [b.id, b.content_hash]))

  const blocks = splitMarkdownIntoBlocks(content)
  const seenIds = new Set<string>()

  for (const block of blocks) {
    seenIds.add(block.id)
    const blockHash = contentHash(block.content)

    if (existingMap.get(block.id) === blockHash) continue

    const blockType = detectBlockType(block.content)

    await db.execute(
      `INSERT INTO blocks (id, page_path, content, depth, block_type, has_id_stamp, content_hash, modified_at)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
       ON CONFLICT(id) DO UPDATE SET
         content = excluded.content,
         depth = excluded.depth,
         block_type = excluded.block_type,
         content_hash = excluded.content_hash,
         modified_at = excluded.modified_at`,
      [
        block.id,
        filePath,
        block.content,
        block.depth,
        blockType,
        0,
        blockHash,
        now,
      ],
    )

    // Re-index links for this block
    await db.execute("DELETE FROM links WHERE source_block_id = $1", [block.id])
    const links = extractLinks(block.content)
    for (const link of links) {
      await db.execute(
        `INSERT INTO links (source_block_id, source_page_path, target, link_type, offset_in_block)
         VALUES ($1, $2, $3, $4, $5)`,
        [block.id, filePath, link.target, link.linkType, link.offset],
      )
    }

    // Re-index properties for this block
    await db.execute("DELETE FROM properties WHERE block_id = $1", [block.id])
    const props = extractProperties(block.content)
    for (const prop of props) {
      await db.execute(
        `INSERT INTO properties (block_id, page_path, key, value)
         VALUES ($1, $2, $3, $4)`,
        [block.id, filePath, prop.key, prop.value],
      )
    }

    // Index task state as a queryable property (e.g. task-state:: TODO)
    const taskState = extractTaskState(block.content)
    if (taskState) {
      await db.execute(
        `INSERT OR REPLACE INTO properties (block_id, page_path, key, value)
         VALUES ($1, $2, $3, $4)`,
        [block.id, filePath, "task-state", taskState],
      )
    }
  }

  // Remove stale blocks
  for (const existingId of existingMap.keys()) {
    if (!seenIds.has(existingId)) {
      await db.execute("DELETE FROM blocks WHERE id = $1", [existingId])
    }
  }
}

/**
 * Remove a file and all its blocks/links from the index.
 * Cascading deletes via the FK constraints handle blocks and links.
 */
export async function removeFile(
  db: Database,
  filePath: string,
): Promise<void> {
  await db.execute("DELETE FROM pages WHERE path = $1", [filePath])
}

/**
 * Rebuild the entire index from scratch.
 * Deletes all existing index data (FK cascade handles blocks, links, FTS),
 * then re-indexes every markdown file found on disk.
 * Skips files that fail to read (logs warning, continues).
 */
export async function fullReindex(
  opts: IndexerOptions,
  db: Database,
): Promise<void> {
  // Wipe clean — FK cascade handles blocks, links, and FTS
  await db.execute("DELETE FROM pages")

  const allFiles = await opts.listDirectory(opts.workspacePath)
  const mdFiles = allFiles.filter((f) => f.endsWith(".md"))

  for (const absolutePath of mdFiles) {
    try {
      const content = await opts.readFile(absolutePath)

      // Normalize to workspace-relative path for the index.
      // Storing relative paths means backlink source_page_path can be
      // passed directly to the router for navigation.
      const relativePath = toRelativePath(absolutePath, opts.workspacePath)

      await indexFile(db, relativePath, content)
    } catch (e) {
      console.warn(`[indexer] failed to index ${absolutePath}:`, e)
    }
  }
}

/**
 * Incrementally sync the index with the filesystem on app startup.
 * Skips parsing and SQL writes for files whose content hash hasn't changed.
 *
 * Flow for each file on disk:
 * - **New** (not in DB) — read, hash, full indexFile
 * - **Changed** (in DB, hash differs) — read, hash, full indexFile
 * - **Unchanged** (in DB, hash matches) — skip parsing and SQL writes
 * - **Deleted** (in DB, not on disk) — remove from index (FK cascade)
 *
 * @remarks The file watcher starts before this sync completes. If a watched
 * file changes mid-sync, the watcher fires `indexFile` on that path
 * concurrently. Worst case: a stale index entry that corrects on the next
 * change. Low probability, acceptable for startup.
 */
export async function syncIndex(
  opts: IndexerOptions,
  db: Database,
): Promise<void> {
  const allFiles = await opts.listDirectory(opts.workspacePath)
  const mdFiles = new Set(allFiles.filter((f) => f.endsWith(".md")))
  const relativePaths = new Set<string>()

  for (const absolutePath of mdFiles) {
    const relativePath = toRelativePath(absolutePath, opts.workspacePath)
    relativePaths.add(relativePath)

    try {
      const content = await opts.readFile(absolutePath)
      const hash = contentHash(content)

      const rows = await db.select<{ content_hash: string }[]>(
        "SELECT content_hash FROM pages WHERE path = $1",
        [relativePath],
      )

      // Skip unchanged files — no parsing, no SQL writes
      if (rows.length > 0 && rows[0].content_hash === hash) continue

      await indexFile(db, relativePath, content)
    } catch (e) {
      console.warn(`[indexer] failed to sync ${absolutePath}:`, e)
    }
  }

  // Remove files that no longer exist on disk
  const indexed = await db.select<{ path: string }[]>("SELECT path FROM pages")
  for (const row of indexed) {
    if (!relativePaths.has(row.path)) {
      await db.execute("DELETE FROM pages WHERE path = $1", [row.path])
    }
  }
}
