import fs from 'node:fs';
import path from 'node:path';
import { scanFile } from './scan-file.js';
import { upsertCanopyEntry, deleteCanopyEntry } from './upsert.js';
import { createLayeredExcludeMatcher } from '../exclude.js';
import { epochSeconds } from '@myco/constants.js';
import type { Database } from 'bun:sqlite';

export interface RescanSingleOptions {
  db: Database;
  projectId: string;
  machineId: string;
  projectRoot: string;
  /** Repo-relative or absolute path; absolute is normalised against projectRoot. */
  filePath: string;
  /** Myco baseline from `canopy.exclude.default_patterns`; falls back to none. */
  defaultExcludePatterns?: string[];
  /** Optional `canopy.exclude.patterns` list; falls back to none. */
  excludePatterns?: string[];
  maxBytes?: number;
}

export type RescanSingleResult =
  | { ok: true; action: 'upserted' | 'deleted'; relPath: string }
  | { ok: false; reason: 'outside_project' | 'skipped' | 'excluded'; relPath: string };

/**
 * Re-scan a single file in response to a Write/Edit/Delete tool event.
 * Cheap and idempotent — one stat, at most one read, one upsert. The path
 * is normalised against the project root so absolute paths from tool inputs
 * resolve identically to relative ones, and paths outside the project are
 * rejected so we never index files we don't own.
 */
export function rescanSingle(opts: RescanSingleOptions): RescanSingleResult {
  const rel = relativise(opts.projectRoot, opts.filePath);
  if (rel === null) return { ok: false, reason: 'outside_project', relPath: opts.filePath };

  // Apply the same layered exclude matcher used by full/delta scans so a
  // tool-use event for a gitignored or managed path doesn't sneak a row
  // into canopy_entries that the next full scan would just tombstone.
  const isExcluded = createLayeredExcludeMatcher({
    projectRoot: opts.projectRoot,
    defaultPatterns: opts.defaultExcludePatterns ?? [],
    userPatterns: opts.excludePatterns ?? [],
  });
  if (isExcluded(rel, false)) {
    // If a stale row exists for this path under the old (laxer) matcher,
    // clean it up while we're here.
    deleteCanopyEntry(opts.db, opts.projectId, rel);
    return { ok: false, reason: 'excluded', relPath: rel };
  }

  if (!fs.existsSync(path.join(opts.projectRoot, rel))) {
    deleteCanopyEntry(opts.db, opts.projectId, rel);
    return { ok: true, action: 'deleted', relPath: rel };
  }

  const result = scanFile({
    projectId: opts.projectId,
    machineId: opts.machineId,
    projectRoot: opts.projectRoot,
    relPath: rel,
    now: epochSeconds(),
    maxBytes: opts.maxBytes,
  });
  if (!result.ok) {
    // Reconcile the row when the file is now unindexable for a deterministic
    // reason — full/delta scans tombstone these via the visited-set, but
    // event-driven rescans need to do it inline or `/canopy/inject` will
    // keep handing the agent stale anatomy until the next periodic scan.
    // `read_error` is left alone since it's typically transient (mid-flight
    // rename, EBUSY) and the next Write/Edit will retry naturally.
    if (result.reason === 'binary' || result.reason === 'too_large' || result.reason === 'symlink') {
      deleteCanopyEntry(opts.db, opts.projectId, rel);
      return { ok: true, action: 'deleted', relPath: rel };
    }
    return { ok: false, reason: 'skipped', relPath: rel };
  }

  // Skip the upsert when content_hash matches the stored row — Write/Edit
  // events that produce identical bytes (no-op edits, idempotent reformats)
  // shouldn't bump mechanical_updated_at and shouldn't re-queue any Tier 2
  // description for re-generation.
  const prior = opts.db
    .prepare('SELECT content_hash FROM canopy_entries WHERE project_id = ? AND path = ?')
    .get(opts.projectId, rel) as { content_hash: string } | undefined;
  if (prior && prior.content_hash === result.entry.content_hash) {
    return { ok: true, action: 'upserted', relPath: rel };
  }

  upsertCanopyEntry(opts.db, result.entry);
  return { ok: true, action: 'upserted', relPath: rel };
}

function relativise(projectRoot: string, filePath: string): string | null {
  const abs = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(projectRoot, filePath);
  const rootAbs = path.resolve(projectRoot);
  const rel = path.relative(rootAbs, abs);
  if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
  return rel.split(path.sep).join('/');
}
