/*
 * build-linear-crossref — distill the web-interact Linear issue scrapes into a
 * canonical JSON the dashboard's Cross-Forge Topology view can serve.
 *
 * There is NO live Linear API; the only source is the Chrome-CDP scrapes the
 * web-interact skill drops under ~/Desktop/web-interact/skalpel-linear-SKA-<n>/
 * <timestamp>/body.txt. This script takes the latest run per ticket, anchor-
 * parses each issue page (status / assignee / PR), and writes
 * data/linear-crossref.json. Re-run after a fresh scrape to refresh the lane.
 *
 *   tsx scripts/build-linear-crossref.ts
 *
 * Forward-looking: a board scrape with `--extract-script` could emit listings.json
 * directly (cleaner than text parsing); this parser is the no-extra-tooling path.
 */
import fs from 'fs';
import os from 'os';
import path from 'path';

const WEB_INTERACT_DIR = path.join(os.homedir(), 'Desktop', 'web-interact');
// Mirror jsonStorage.resolveDataDir(): repo-local data/ unless overridden.
const DATA_DIR = process.env.PRIORITY_FORGE_DATA_DIR || path.join(__dirname, '..', 'data');
const OUT_FILE = path.join(DATA_DIR, 'linear-crossref.json');

// Linear workflow states that render as a standalone line on an issue page.
const STATUS_KEYWORDS = [
  'Done', 'In Progress', 'In Review', 'Todo', 'Backlog',
  'Canceled', 'Cancelled', 'Triage', 'Duplicate',
];
const PR_STATES = ['Merged', 'Open', 'Draft', 'Closed', 'In Review'];

/** Decode the handful of HTML entities Linear's innerText leaks (&amp; etc.). */
function decodeEntities(s: string): string {
  return s
    .replace(/&amp;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&#39;/g, "'")
    .replace(/&quot;/g, '"');
}

interface LinearTicket {
  id: string;
  title: string | null;
  status: string | null;
  assignee: string | null;
  pr: string | null;
  prState: string | null;
  scrapedAt: string;
  runDir: string;
}

/** Latest `<ISO-timestamp>/` run dir under a ticket's scrape folder, or null. */
function latestRunDir(ticketDir: string): string | null {
  const full = path.join(WEB_INTERACT_DIR, ticketDir);
  if (!fs.existsSync(full)) return null;
  const runs = fs
    .readdirSync(full)
    .filter((d) => /^\d{4}-\d{2}-\d{2}T/.test(d))
    .sort();
  return runs.length ? path.join(full, runs[runs.length - 1]) : null;
}

/** Anchor-parse an issue page's innerText. Layout differs between Done (status
 *  near title) and Backlog (status under Properties), so we search, not index. */
function parseBody(body: string): Omit<LinearTicket, 'scrapedAt' | 'runDir'> | null {
  const lines = body.split('\n').map((l) => l.trim());

  let id: string | null = null;
  let title: string | null = null;
  for (const l of lines) {
    const m = l.match(/^(SKA-\d+)(.*)$/);
    if (m) {
      id = m[1];
      title = (m[2] || '').trim() ? decodeEntities((m[2] || '').trim()) : null;
      break;
    }
  }
  if (!id) return null;

  // Status: first standalone status keyword. Activity text ("moved from Backlog
  // to Done·3d ago") is never standalone, so it won't false-match.
  let status: string | null = null;
  for (const l of lines) {
    if (STATUS_KEYWORDS.includes(l)) { status = l; break; }
  }

  // Assignee: nearest non-empty line preceding "self-assigned"/"assigned the
  // issue" (excluding "created the issue"). Unassigned issues have neither.
  let assignee: string | null = null;
  for (let i = 1; i < lines.length; i++) {
    if (/(self-assigned|assigned the issue)/i.test(lines[i]) && !/created/i.test(lines[i])) {
      for (let j = i - 1; j >= 0; j--) {
        if (lines[j]) { assignee = lines[j]; break; }
      }
      break;
    }
  }

  // PR: a `#<num>` line followed (within a few lines) by a PR-state word.
  let pr: string | null = null;
  let prState: string | null = null;
  for (let i = 0; i < lines.length; i++) {
    const m = lines[i].match(/^#(\d+)$/);
    if (!m) continue;
    for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
      if (PR_STATES.includes(lines[j])) { pr = `#${m[1]}`; prState = lines[j]; break; }
    }
    if (pr) break;
  }

  return { id, title, status, assignee, pr, prState };
}

function main(): void {
  if (!fs.existsSync(WEB_INTERACT_DIR)) {
    console.error(`web-interact dir not found: ${WEB_INTERACT_DIR}`);
    process.exit(1);
  }
  const ticketDirs = fs
    .readdirSync(WEB_INTERACT_DIR)
    .filter((d) => /^skalpel-linear-SKA-\d+$/.test(d)); // excludes -verify / board scrapes

  const tickets: Record<string, LinearTicket> = {};
  for (const dir of ticketDirs) {
    const run = latestRunDir(dir);
    if (!run) continue;
    const bodyPath = path.join(run, 'body.txt');
    if (!fs.existsSync(bodyPath)) continue;
    const parsed = parseBody(fs.readFileSync(bodyPath, 'utf8'));
    if (!parsed) continue;
    tickets[parsed.id] = {
      ...parsed,
      scrapedAt: fs.statSync(bodyPath).mtime.toISOString(),
      runDir: path.relative(WEB_INTERACT_DIR, run),
    };
  }

  const scrapedAt =
    Object.values(tickets).map((t) => t.scrapedAt).sort().pop() || null;
  const payload = {
    generatedAt: new Date().toISOString(),
    scrapedAt,
    source: 'web-interact CDP scrapes (~/Desktop/web-interact/skalpel-linear-SKA-*)',
    ticketCount: Object.keys(tickets).length,
    tickets,
  };

  fs.mkdirSync(DATA_DIR, { recursive: true });
  fs.writeFileSync(OUT_FILE, JSON.stringify(payload, null, 2));

  console.log(`wrote ${payload.ticketCount} tickets → ${OUT_FILE}`);
  for (const [tid, t] of Object.entries(tickets).sort()) {
    console.log(
      `  ${tid.padEnd(7)} ${(t.status || '?').padEnd(12)} ${(t.assignee || '—').padEnd(18)} ${t.pr || ''} ${t.prState || ''}  | ${t.title || ''}`,
    );
  }
}

main();
