#!/usr/bin/env node

import fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PACKAGE_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(PACKAGE_ROOT, '..', '..');
const TEST_ROOT = path.join(PACKAGE_ROOT, 'src');
const CANONICAL_SPEC = path.join(REPO_ROOT, 'openspec', 'specs', 'docx-comparison', 'spec.md');
const DEFAULT_MATRIX_PATH = path.join(TEST_ROOT, 'testing', 'DOCX_COMPARISON_OPENSPEC_TRACEABILITY.md');

function normalizeScenarioName(value) {
  return value
    .replace(/^Scenario:\s*/i, '')
    .replace(/^\[[^\]]+\]\s*/, '')
    .trim()
    .replace(/\s+/g, ' ');
}

const SERIAL_ID_RE = /^(?:SDX|OA)-[\w-]+-?\d+$/;

function extractScenarioId(rawScenario) {
  const match = rawScenario.trim().match(/^\[([^\]]+)\]/);
  return match ? match[1].trim() : null;
}

function parseSerialIdMap(specContent) {
  const map = new Map();
  const re = /^\s*####\s+Scenario:\s*\[([^\]]+)\]\s*(.+?)\s*$/gm;
  let m;
  while ((m = re.exec(specContent))) {
    map.set(m[1], m[2].replace(/\s+/g, ' ').trim());
  }
  return map;
}

function resolveSerialIds(stories, serialIdMap) {
  const resolved = new Set();
  for (const story of stories) {
    if (SERIAL_ID_RE.test(story) && serialIdMap.has(story)) {
      resolved.add(serialIdMap.get(story));
    } else {
      resolved.add(story);
    }
  }
  return resolved;
}

function parseScenariosFromSpec(content) {
  const scenarios = [];
  const seen = new Set();
  const scenarioHeader = /^\s*####\s+Scenario:\s*(.+?)\s*$/gm;
  let m = scenarioHeader.exec(content);
  while (m) {
    const raw = m[1].trim();
    const name = normalizeScenarioName(raw);
    if (seen.has(name)) {
      m = scenarioHeader.exec(content);
      continue;
    }
    seen.add(name);
    scenarios.push({ name, id: extractScenarioId(raw) });
    m = scenarioHeader.exec(content);
  }
  return scenarios;
}

function parseStoriesFromTest(content) {
  const stories = new Set();
  const storyIdsByName = new Map();

  function addStory(rawValue) {
    const normalized = normalizeScenarioName(rawValue);
    stories.add(normalized);
    const id = extractScenarioId(rawValue);
    if (!id) {
      return;
    }
    const ids = storyIdsByName.get(normalized) ?? new Set();
    ids.add(id);
    storyIdsByName.set(normalized, ids);
  }

  const viaOpenspec = /\.openspec\(\s*(["'`])([\s\S]*?)\1\s*\)/g;
  let m = viaOpenspec.exec(content);
  while (m) {
    addStory(m[2]);
    m = viaOpenspec.exec(content);
  }

  const viaAllureStory = /allure\.story\(\s*(["'`])([\s\S]*?)\1\s*\)/g;
  m = viaAllureStory.exec(content);
  while (m) {
    addStory(m[2]);
    m = viaAllureStory.exec(content);
  }

  return { stories, storyIdsByName };
}

function parseSkippedStoriesFromTest(content) {
  const skipped = new Set();
  const skippedPattern = /(?:test|it)\.(?:skip|todo)\(\s*(["'`])(?:Scenario:\s*)?([\s\S]*?)\1/g;
  let m = skippedPattern.exec(content);
  while (m) {
    skipped.add(normalizeScenarioName(m[2]));
    m = skippedPattern.exec(content);
  }
  return skipped;
}

async function listFilesRecursively(rootDir, predicate) {
  const out = [];

  async function walk(dir) {
    const entries = await fs.readdir(dir, { withFileTypes: true });
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        await walk(full);
        continue;
      }
      if (predicate(full)) out.push(full);
    }
  }

  await walk(rootDir);
  return out.sort();
}

function mdEscapeTableCell(value) {
  return String(value)
    .replace(/\|/g, '\\|')
    .replace(/\r?\n/g, ' ')
    .trim();
}

function buildMatrixMarkdown({ scenarios, storyToFiles, skippedStories }) {
  const lines = [];
  lines.push('# DOCX Comparison OpenSpec Traceability Matrix');
  lines.push('');
  lines.push('> Auto-generated by `packages/docx-core/scripts/validate_openspec_coverage.mjs`.');
  lines.push('> Do not hand-edit this file.');
  lines.push('');
  lines.push('This matrix maps canonical spec `#### Scenario:` entries to scenario mappings extracted from `src/**/*.test.ts`.');
  lines.push('');
  lines.push('| Scenario | Status | Test Files | Notes |');
  lines.push('|---|---|---|---|');

  for (const scenario of scenarios) {
    const files = storyToFiles.get(scenario) ?? [];
    const isSkipped = skippedStories.has(scenario);

    const status = isSkipped
      ? 'pending_impl'
      : files.length > 0
        ? 'covered'
        : 'missing';

    const fileCell = files.length > 0
      ? files.map((f) => `\`${f}\``).join(', ')
      : 'n/a';

    let notes = '';
    if (isSkipped) notes = 'skip/todo in tests';
    else if (files.length === 0) notes = 'No scenario mapping found in current tests';

    lines.push(
      `| ${mdEscapeTableCell(scenario)} | ${status} | ${mdEscapeTableCell(fileCell)} | ${mdEscapeTableCell(notes)} |`,
    );
  }

  lines.push('');
  return `${lines.join('\n')}\n`;
}

function parseArgs() {
  const args = process.argv.slice(2);
  let writeMatrixPath = DEFAULT_MATRIX_PATH;
  let strict = false;

  for (let i = 0; i < args.length; i += 1) {
    const arg = args[i];
    if (arg === '--write-matrix') {
      const value = args[i + 1];
      if (value && !value.startsWith('--')) {
        writeMatrixPath = path.resolve(process.cwd(), value);
        i += 1;
      }
      continue;
    }
    if (arg === '--strict') {
      strict = true;
      continue;
    }
    throw new Error(`Unknown argument: ${arg}`);
  }

  return { writeMatrixPath, strict };
}

async function main() {
  const { writeMatrixPath, strict } = parseArgs();

  let canonicalSpec;
  try {
    canonicalSpec = await fs.readFile(CANONICAL_SPEC, 'utf-8');
  } catch {
    console.error(`Cannot read canonical spec: ${CANONICAL_SPEC}`);
    process.exitCode = 1;
    return;
  }

  const scenarioEntries = parseScenariosFromSpec(canonicalSpec);
  const serialIdMap = parseSerialIdMap(canonicalSpec);
  const scenarios = scenarioEntries.map((entry) => entry.name).sort();
  const scenarioLookup = new Set(scenarios);
  if (scenarios.length === 0) {
    console.error(`No '#### Scenario:' entries found in ${CANONICAL_SPEC}`);
    process.exitCode = 1;
    return;
  }

  const testFiles = await listFilesRecursively(TEST_ROOT, (f) => f.endsWith('.test.ts'));

  const storyToFiles = new Map();
  const storySet = new Set();
  const storyIdsByName = new Map();
  const skippedStorySet = new Set();

  for (const tf of testFiles) {
    const content = await fs.readFile(tf, 'utf-8');
    const rel = path.relative(PACKAGE_ROOT, tf).split(path.sep).join('/');

    const parsedStories = parseStoriesFromTest(content);
    for (const story of resolveSerialIds(parsedStories.stories, serialIdMap)) {
      storySet.add(story);
      const files = storyToFiles.get(story) ?? [];
      if (!files.includes(rel)) files.push(rel);
      storyToFiles.set(story, files);
    }
    for (const [story, ids] of parsedStories.storyIdsByName.entries()) {
      const resolvedName = SERIAL_ID_RE.test(story) && serialIdMap.has(story)
        ? serialIdMap.get(story)
        : story;
      if (!scenarioLookup.has(resolvedName)) {
        continue;
      }
      const existing = storyIdsByName.get(resolvedName) ?? new Set();
      for (const id of ids) {
        existing.add(id);
      }
      storyIdsByName.set(resolvedName, existing);
    }

    for (const skipped of resolveSerialIds(parseSkippedStoriesFromTest(content), serialIdMap)) {
      skippedStorySet.add(skipped);
    }
  }

  const missing = scenarios.filter((scenario) => !storySet.has(scenario));
  const pending = scenarios.filter((scenario) => skippedStorySet.has(scenario));
  const covered = scenarios.length - missing.length;
  const extra = [...storySet].filter((story) => !scenarioLookup.has(story)).sort();
  const scenarioIdIssues = [];
  for (const scenario of scenarioEntries) {
    if (!scenario.id) {
      continue;
    }
    if (!storySet.has(scenario.name)) {
      continue;
    }
    const mappedIds = storyIdsByName.get(scenario.name) ?? new Set();
    if (mappedIds.size === 0) {
      scenarioIdIssues.push(
        `${scenario.name}: expected ID [${scenario.id}] in test .openspec(...) mapping, but no ID was found`,
      );
      continue;
    }
    if (!mappedIds.has(scenario.id)) {
      scenarioIdIssues.push(
        `${scenario.name}: expected ID [${scenario.id}], but found [${[...mappedIds].sort().join(', ')}]`,
      );
    }
  }

  const matrix = buildMatrixMarkdown({
    scenarios,
    storyToFiles,
    skippedStories: skippedStorySet,
  });

  await fs.mkdir(path.dirname(writeMatrixPath), { recursive: true });
  await fs.writeFile(writeMatrixPath, matrix, 'utf-8');

  const pct = ((covered / scenarios.length) * 100).toFixed(1);
  if (missing.length === 0 && pending.length === 0 && scenarioIdIssues.length === 0) {
    const extraSuffix = extra.length > 0 ? ` (+${extra.length} additional stories not in spec)` : '';
    console.log(`PASS docx-comparison: ${covered}/${scenarios.length} scenarios mapped (${pct}%)${extraSuffix}`);
  } else {
    const label = strict ? 'FAIL' : 'WARN';
    console.error(`${label} docx-comparison: ${covered}/${scenarios.length} scenarios mapped (${pct}%)`);
    if (missing.length > 0) {
      console.error(`  Missing scenarios (${missing.length}):`);
      for (const scenario of missing) {
        console.error(`    - ${scenario}`);
      }
    }
    if (pending.length > 0) {
      console.error(`  Pending scenarios (${pending.length}):`);
      for (const scenario of pending) {
        console.error(`    - ${scenario}`);
      }
    }
    if (scenarioIdIssues.length > 0) {
      console.error(`  Scenario ID mismatches (${scenarioIdIssues.length}):`);
      for (const issue of scenarioIdIssues) {
        console.error(`    - ${issue}`);
      }
    }
    if (strict) process.exitCode = 1;
  }

  if (extra.length > 0) {
    console.log(`Extra scenario stories not in spec (${extra.length}):`);
    for (const scenario of extra) {
      console.log(`  - ${scenario}`);
    }
  }

  const relative = path.relative(REPO_ROOT, writeMatrixPath).split(path.sep).join('/');
  console.log(`Wrote traceability matrix: ${relative}`);
}

await main();
