import { readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { z } from "zod";
import type { Adapter } from "../adapter";
import type { AdapterResult, Panel } from "../schema";

export type GitRunner = (args: string[], cwd: string) => Promise<string>;

export interface GoliveAdapterOptions {
  id?: string;
  interval?: number;
  /** Absolute repo paths to read `<repo>/GOLIVE.md` from. */
  repos?: string[];
  /** commitsSinceLastWorksDelta above this fires churn:true. */
  churnThreshold?: number;
  runGit?: GitRunner;
  readFileImpl?: (path: string) => string;
  now?: () => number;
}

const VerdictSchema = z.enum(["works", "broken", "missing", "unverified"]);
export type Verdict = z.infer<typeof VerdictSchema>;

const CriterionSchema = z.object({
  id: z.string().min(1),
  text: z.string(),
  verdict: VerdictSchema,
  evidence: z.string().nullable(),
  section: z.string().nullable(),
  reachedAt: z.string().nullable(),
});

const RepoScoreSchema = z.object({
  repo: z.string().min(1),
  works: z.number().int().nonnegative(),
  total: z.number().int().nonnegative(),
  verdicts: z.object({
    works: z.number().int().nonnegative(),
    broken: z.number().int().nonnegative(),
    missing: z.number().int().nonnegative(),
    unverified: z.number().int().nonnegative(),
  }),
  criteria: z.array(CriterionSchema),
  title: z.string().nullable(),
  summary: z.string().nullable(),
  trend7d: z.number().int(),
  churn: z.boolean(),
  commitsSinceLastWorksDelta: z.number().int().nonnegative(),
  sourceUpdatedAt: z.string().nullable(),
});
export type RepoScore = z.infer<typeof RepoScoreSchema>;

const GolivePanelDataSchema = z.object({ repos: z.array(RepoScoreSchema) });

// AC-03 target repos with a real GOLIVE.md on this box. press-zone-backend lives inside
// the wordpress/wp-content repo (not its own git root) — the `:./` historical reads below
// keep git archaeology correct for that subdir case.
const DEFAULT_REPOS = [
  "/home/user/Projects/multideal",
  "/home/user/Projects/zync.is",
  "/home/user/Projects/Press.zone/wordpress/wp-content/press-zone-backend",
];
const DEFAULT_INTERVAL_MS = 5 * 60_000;
const DEFAULT_CHURN_THRESHOLD = 10;
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const GOLIVE_FILENAME = "GOLIVE.md";

const FENCE = /^\s*```/;
const H1 = /^#\s+(.*)$/;
const HEADING = /^#{2,}\s+(.*)$/;
const CRITERION = /^\s*-\s+\[( |x|X)\]\s*(.*)$/;
const VERDICT_TAG = /\[(WORKS|BROKEN|MISSING|UNVERIFIED)\]/i;
const AC_ID = /\bAC-\d+\b/;
const EVIDENCE = /(?:Evidence|verify):\s*(.*)$/i;
const SUMMARY_LINE = /\bWORKS\b.*\b(?:BROKEN|MISSING|UNVERIFIED|criteria)\b/i;

interface ParsedCriterion extends z.infer<typeof CriterionSchema> {
  /** 1-based source line, used to attach git-blame dates. Stripped before emit. */
  line: number;
}

interface ParsedGolive {
  works: number;
  total: number;
  verdicts: RepoScore["verdicts"];
  criteria: ParsedCriterion[];
  title: string | null;
  summary: string | null;
}

/**
 * Parses a GOLIVE.md into per-criterion rows. A criterion's verdict comes from a
 * `[WORKS|BROKEN|MISSING|UNVERIFIED]` tag when present (the dogfooded format), else
 * falls back to the checkbox flag (`[x]`→works, `[ ]`→unverified) so plain checkbox
 * GOLIVE files still count. Fenced code blocks are ignored.
 */
function parseGolive(markdown: string): ParsedGolive {
  let inFence = false;
  let section: string | null = null;
  let title: string | null = null;
  let summary: string | null = null;
  const criteria: ParsedCriterion[] = [];

  const lines = markdown.split("\n");
  for (let i = 0; i < lines.length; i += 1) {
    const raw = lines[i] ?? "";
    if (FENCE.test(raw)) {
      inFence = !inFence;
      continue;
    }
    if (inFence) continue;

    const criterion = CRITERION.exec(raw);
    if (criterion) {
      const flag = criterion[1] ?? " ";
      const rest = (criterion[2] ?? "").trim();
      const tag = VERDICT_TAG.exec(rest);
      const verdict: Verdict = tag
        ? (tag[1]?.toLowerCase() as Verdict)
        : flag === " "
          ? "unverified"
          : "works";
      const idMatch = AC_ID.exec(rest);
      const id = idMatch ? idMatch[0] : `#${criteria.length + 1}`;
      const evMatch = EVIDENCE.exec(rest);
      const evidence = evMatch ? (evMatch[1] ?? "").trim() : null;
      const text = (evMatch ? rest.slice(0, evMatch.index) : rest)
        .replace(AC_ID, "")
        .replace(VERDICT_TAG, "")
        .replace(/^\s*[—-]\s*/, "")
        .replace(/[\s.]+$/, "")
        .trim();
      criteria.push({ id, text, verdict, evidence, section, reachedAt: null, line: i + 1 });
      continue;
    }

    const h1 = H1.exec(raw);
    if (h1 && title === null) {
      const h1Text = (h1[1] ?? "").trim();
      title = h1Text.replace(/^GOLIVE\s*[—:-]\s*/i, "").trim() || h1Text;
      continue;
    }
    const heading = HEADING.exec(raw);
    if (heading) {
      section = (heading[1] ?? "").trim();
      continue;
    }
    if (summary === null && SUMMARY_LINE.test(raw)) {
      summary = raw.replace(/\*\*/g, "").trim();
    }
  }

  const verdicts = { works: 0, broken: 0, missing: 0, unverified: 0 };
  for (const c of criteria) verdicts[c.verdict] += 1;
  return { works: verdicts.works, total: criteria.length, verdicts, criteria, title, summary };
}

async function defaultRunGit(args: string[], cwd: string): Promise<string> {
  const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
  const [stdout, stderr, exitCode] = await Promise.all([
    new Response(proc.stdout).text(),
    new Response(proc.stderr).text(),
    proc.exited,
  ]);
  if (exitCode !== 0) {
    throw new Error(`git ${args.join(" ")} failed (${exitCode}): ${stderr.trim()}`);
  }
  return stdout;
}

/**
 * The committed scoreboard on the canonical remote branch is the source of truth —
 * a stale or dirty developer checkout must never shadow it (verdicts land on main
 * from a detached runner worktree, so the local tree can lag behind origin).
 * Best-effort fetch, then prefer origin/main, origin/master. Null → no remote
 * branch (offline clone, subdir repo) → callers fall back to the working tree.
 */
async function resolveSourceRef(runGit: GitRunner, repoPath: string): Promise<string | null> {
  try {
    await runGit(["fetch", "--quiet", "origin"], repoPath);
  } catch {
    // offline or no remote — a previously fetched origin/<branch> is still usable
  }
  for (const ref of ["origin/main", "origin/master"]) {
    try {
      await runGit(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], repoPath);
      return ref;
    } catch {
      // try the next candidate
    }
  }
  return null;
}

/** `[ref]` when anchored to the remote branch, `[]` for working-tree/HEAD mode. */
function refArgs(ref: string | null): string[] {
  return ref === null ? [] : [ref];
}

async function worksCountAtRef(
  runGit: GitRunner,
  repoPath: string,
  ref: string,
): Promise<number | undefined> {
  try {
    // `:./` resolves relative to cwd — correct when repoPath is a subdir of a larger repo,
    // and identical to root-relative at a repo root. Plain `:GOLIVE.md` is top-level-relative.
    const content = await runGit(["show", `${ref}:./${GOLIVE_FILENAME}`], repoPath);
    return parseGolive(content).works;
  } catch {
    return undefined;
  }
}

async function computeTrend7d(
  runGit: GitRunner,
  repoPath: string,
  ref: string | null,
  worksNow: number,
  nowMs: number,
): Promise<number> {
  const cutoffIso = new Date(nowMs - SEVEN_DAYS_MS).toISOString();
  let raw: string;
  try {
    raw = await runGit(
      ["log", "-1", ...refArgs(ref), "--format=%H", `--until=${cutoffIso}`, "--", GOLIVE_FILENAME],
      repoPath,
    );
  } catch {
    return 0;
  }
  const hash = raw.trim();
  if (hash === "") return worksNow; // no commit before cutoff: whole current count is within window
  const worksThen = await worksCountAtRef(runGit, repoPath, hash);
  return worksThen === undefined ? worksNow : worksNow - worksThen;
}

async function computeChurn(
  runGit: GitRunner,
  repoPath: string,
  ref: string | null,
  churnThreshold: number,
): Promise<{ churn: boolean; commitsSinceLastWorksDelta: number }> {
  let log: string;
  try {
    log = await runGit(["log", ...refArgs(ref), "--format=%H", "--", GOLIVE_FILENAME], repoPath);
  } catch {
    return { churn: false, commitsSinceLastWorksDelta: 0 };
  }
  const hashes = log
    .split("\n")
    .map((line) => line.trim())
    .filter((line) => line !== "");
  const firstHash = hashes[0];
  if (firstHash === undefined) {
    return { churn: false, commitsSinceLastWorksDelta: 0 };
  }

  const worksCache = new Map<string, number>();
  async function worksAt(hash: string): Promise<number | undefined> {
    const cached = worksCache.get(hash);
    if (cached !== undefined) return cached;
    const value = await worksCountAtRef(runGit, repoPath, hash);
    if (value !== undefined) worksCache.set(hash, value);
    return value;
  }

  // Walk newest-to-oldest pairs; the first count change found is where the flip
  // landed. No change anywhere in history: fall back to the oldest known commit.
  let changeCommit = firstHash;
  for (let i = 0; i < hashes.length - 1; i += 1) {
    const newer = hashes[i];
    const older = hashes[i + 1];
    if (newer === undefined || older === undefined) break;
    const worksNewer = await worksAt(newer);
    const worksOlder = await worksAt(older);
    if (worksNewer === undefined || worksOlder === undefined) break;
    if (worksNewer !== worksOlder) {
      changeCommit = newer;
      break;
    }
    changeCommit = older;
  }

  let countRaw: string;
  try {
    countRaw = await runGit(["rev-list", "--count", `${changeCommit}..${ref ?? "HEAD"}`], repoPath);
  } catch {
    return { churn: false, commitsSinceLastWorksDelta: 0 };
  }
  const commitsSinceLastWorksDelta = Number.parseInt(countRaw.trim(), 10) || 0;
  return {
    churn: commitsSinceLastWorksDelta > churnThreshold,
    commitsSinceLastWorksDelta,
  };
}

/** ISO instant of the newest commit touching GOLIVE.md. Never-committed file → null. */
async function computeSourceUpdatedAt(
  runGit: GitRunner,
  repoPath: string,
  ref: string | null,
): Promise<string | null> {
  let raw: string;
  try {
    raw = await runGit(
      ["log", "-1", ...refArgs(ref), "--format=%cI", "--", GOLIVE_FILENAME],
      repoPath,
    );
  } catch {
    return null;
  }
  const iso = raw.trim();
  if (iso === "") return null;
  const parsed = new Date(iso);
  return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
}

/**
 * Maps each criterion's source line to the date its line was last committed (the date the
 * criterion reached its current wording/verdict). Uncommitted lines (untracked GOLIVE.md,
 * or unstaged edits) blame as the all-zero SHA and are left null. Blame failure → empty map.
 */
async function computeReachedDates(
  runGit: GitRunner,
  repoPath: string,
  ref: string | null,
): Promise<Map<number, string>> {
  const dates = new Map<number, string>();
  let porcelain: string;
  try {
    porcelain = await runGit(
      ["blame", "--line-porcelain", ...refArgs(ref), "--", GOLIVE_FILENAME],
      repoPath,
    );
  } catch {
    return dates;
  }
  let finalLine: number | null = null;
  let sha: string | null = null;
  let committerTime: number | null = null;
  for (const line of porcelain.split("\n")) {
    const header = /^([0-9a-f]{40})\s+\d+\s+(\d+)(?:\s+\d+)?$/.exec(line);
    if (header) {
      sha = header[1] ?? null;
      finalLine = Number.parseInt(header[2] ?? "", 10);
      committerTime = null;
      continue;
    }
    const time = /^committer-time (\d+)$/.exec(line);
    if (time) {
      committerTime = Number.parseInt(time[1] ?? "", 10);
      continue;
    }
    if (line.startsWith("\t")) {
      const committed = sha !== null && !/^0+$/.test(sha);
      if (committed && finalLine !== null && committerTime !== null) {
        dates.set(finalLine, new Date(committerTime * 1000).toISOString().slice(0, 10));
      }
      finalLine = null;
      sha = null;
      committerTime = null;
    }
  }
  return dates;
}

async function scoreRepo(
  repoPath: string,
  runGit: GitRunner,
  readFileImpl: (path: string) => string,
  churnThreshold: number,
  nowMs: number,
): Promise<RepoScore | undefined> {
  let ref = await resolveSourceRef(runGit, repoPath);
  let markdown: string | undefined;
  if (ref !== null) {
    try {
      markdown = await runGit(["show", `${ref}:./${GOLIVE_FILENAME}`], repoPath);
    } catch {
      // GOLIVE.md not committed on the remote branch — fall back to the working tree
      ref = null;
    }
  }
  if (markdown === undefined) {
    try {
      markdown = readFileImpl(join(repoPath, GOLIVE_FILENAME));
    } catch {
      return undefined;
    }
  }

  const parsed = parseGolive(markdown);
  const trend7d = await computeTrend7d(runGit, repoPath, ref, parsed.works, nowMs);
  const { churn, commitsSinceLastWorksDelta } = await computeChurn(
    runGit,
    repoPath,
    ref,
    churnThreshold,
  );
  const reached = await computeReachedDates(runGit, repoPath, ref);
  const sourceUpdatedAt = await computeSourceUpdatedAt(runGit, repoPath, ref);

  const criteria = parsed.criteria.map(({ line, ...criterion }) => ({
    ...criterion,
    reachedAt: reached.get(line) ?? null,
  }));

  return {
    repo: basename(repoPath),
    works: parsed.works,
    total: parsed.total,
    verdicts: parsed.verdicts,
    criteria,
    title: parsed.title,
    summary: parsed.summary,
    trend7d,
    churn,
    commitsSinceLastWorksDelta,
    sourceUpdatedAt,
  };
}

/**
 * GOLIVE.md scoreboard adapter. Reads each configured repo's GOLIVE.md from the canonical
 * remote branch (fetch-fresh origin/main|master; working tree only as fallback), scores
 * per-criterion verdicts, and derives trend/churn/dates from that ref's history — panel only.
 */
export function createGoliveAdapter(opts: GoliveAdapterOptions = {}): Adapter {
  const id = opts.id ?? "golive";
  const interval = opts.interval ?? DEFAULT_INTERVAL_MS;
  const repos = opts.repos && opts.repos.length > 0 ? opts.repos : DEFAULT_REPOS;
  const churnThreshold = opts.churnThreshold ?? DEFAULT_CHURN_THRESHOLD;
  const runGit = opts.runGit ?? defaultRunGit;
  const readFileImpl = opts.readFileImpl ?? ((path: string) => readFileSync(path, "utf8"));
  const now = opts.now ?? Date.now;

  async function poll(): Promise<AdapterResult> {
    const nowMs = now();
    const ts = new Date(nowMs).toISOString();

    const scores: RepoScore[] = [];
    for (const repoPath of repos) {
      const score = await scoreRepo(repoPath, runGit, readFileImpl, churnThreshold, nowMs);
      if (score !== undefined) scores.push(score);
    }

    const data = GolivePanelDataSchema.parse({ repos: scores });
    const panels: Panel[] = [{ id: "scoreboard", ts, data }];
    return { items: [], panels };
  }

  return { id, interval, poll };
}
