#!/usr/bin/env node
/**
 * fix-fixture-drift.js
 *
 * Interactive tool for resolving fixture drift reported by the
 * "Fixture drift" Jest test suite (test/unit/fixture-integrity.test.js).
 *
 * Handles two conditions:
 *   ORPHAN — the manifest source path no longer exists on disk.
 *   DRIFT  — the source exists but its content has changed since
 *             the manifest was last updated.
 *
 * Usage:
 *   node scripts/fix-fixture-drift.js
 *
 * Or via the VS Code task: "Fix Fixture Drift"
 */

import * as crypto from "crypto";
import * as fs from "fs";
import * as path from "path";
import * as readline from "readline";

import { fileURLToPath } from "url";
import { dirname } from "path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const scriptDir    = __dirname;
const repoRoot     = path.resolve(scriptDir, "..");
const testBase     = path.join(repoRoot, "test");
const fixtureBase  = path.join(testBase, "unit", "fixtures");
const manifestPath = path.join(fixtureBase, ".manifest.json");
const syntaxBase   = path.join(testBase, "syntax-tests");

// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function sha256(filePath) {
    return crypto
        .createHash("sha256")
        .update(fs.readFileSync(filePath))
        .digest("hex")
        .toUpperCase();
}

/** Same as sha256 but with all CR characters stripped before hashing. */
function sha256Normalized(filePath) {
    const raw = fs.readFileSync(filePath);
    const normalized = Buffer.from(raw.toString("binary").replace(/\r/g, ""), "binary");
    return crypto
        .createHash("sha256")
        .update(normalized)
        .digest("hex")
        .toUpperCase();
}

function relFromTest(absPath) {
    return absPath.replace(testBase + path.sep, "").replace(/\\/g, "/");
}

/**
 * Walk a directory tree, calling cb(absPath) for every file whose name
 * matches the predicate.
 */
function walkFiles(dir, predicate, results = []) {
    if (!fs.existsSync(dir)) return results;
    for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
        const full = path.join(dir, entry.name);
        if (entry.isDirectory()) {
            walkFiles(full, predicate, results);
        } else if (predicate(entry.name)) {
            results.push(full);
        }
    }
    return results;
}

/**
 * Count how many leading hyphen-separated segments two stems share.
 * e.g. "do-forever-break-continue" vs "do-forever-happy-path" → 2 ("do", "forever")
 */
function sharedPrefixSegments(a, b) {
    const pa = a.split("-");
    const pb = b.split("-");
    let i = 0;
    while (i < pa.length && i < pb.length && pa[i] === pb[i]) i++;
    return i;
}

/**
 * Find candidate source files in syntax-tests/ whose basename is related to
 * the fixture's basename stem. Matches on:
 *   - exact stem equality
 *   - candidate stem starts with fixture stem (promoted with suffix added)
 *   - at least 2 leading hyphen-segments in common (handles renames like
 *     "do-forever-break-continue" → "do-forever-happy-path")
 */
function findCandidates(fixtureName) {
    const stem = path.basename(fixtureName, ".TXT").toLowerCase();
    return walkFiles(syntaxBase, (name) => {
        if (!name.toUpperCase().endsWith(".TXT")) return false;
        const candidateStem = path.basename(name, ".TXT").toLowerCase();
        return candidateStem === stem
            || candidateStem.startsWith(stem)
            || sharedPrefixSegments(candidateStem, stem) >= 2;
    });
}

// ---------------------------------------------------------------------------
// Prompt helper
// ---------------------------------------------------------------------------
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });

function ask(question) {
    return new Promise((resolve) => rl.question(question, resolve));
}

async function menu(prompt, options) {
    while (true) {
        console.log(prompt);
        options.forEach((opt, i) => console.log(`  [${i + 1}] ${opt}`));
        const answer = (await ask("Choice: ")).trim();
        const idx = parseInt(answer, 10) - 1;
        if (idx >= 0 && idx < options.length) return idx;
        console.log("  Invalid choice, try again.\n");
    }
}

// ---------------------------------------------------------------------------
// Action handlers
// ---------------------------------------------------------------------------

/**
 * Update the source path for an entry (and optionally re-hash from new source).
 */
function updateSource(entry, newSourceRel) {
    entry.source = newSourceRel;
    const newSourceAbs = path.join(testBase, newSourceRel.replace(/\//g, path.sep));
    entry.sourceHash = sha256(newSourceAbs);
    console.log(`  ✓ Source updated to: ${newSourceRel}`);
    console.log(`  ✓ Hash updated:      ${entry.sourceHash}`);
}

/**
 * Re-copy the fixture from its source and update the stored hash.
 */
function syncFixture(entry) {
    const sourceAbs  = path.join(testBase, entry.source.replace(/\//g, path.sep));
    const fixtureAbs = path.join(fixtureBase, entry.fixture.replace(/\//g, path.sep));
    const content    = fs.readFileSync(sourceAbs);
    fs.mkdirSync(path.dirname(fixtureAbs), { recursive: true });
    fs.writeFileSync(fixtureAbs, content);
    entry.sourceHash = sha256(sourceAbs);
    console.log(`  ✓ Fixture re-synced from source.`);
    console.log(`  ✓ Hash updated: ${entry.sourceHash}`);
}

/**
 * Update sourceHash only (keep fixture content unchanged — intentional divergence).
 */
function updateHashOnly(entry) {
    const sourceAbs = path.join(testBase, entry.source.replace(/\//g, path.sep));
    entry.sourceHash = sha256(sourceAbs);
    console.log(`  ✓ Hash updated to current source hash: ${entry.sourceHash}`);
    console.log(`  ✓ Fixture content unchanged (intentional divergence).`);
}

/**
 * Break the linkage: move the fixture into the fixtureOnly list so it is
 * maintained manually and no longer tracked against a source.
 */
function breakLinkage(manifest, entry) {
    const idx = manifest.fixtures.indexOf(entry);
    manifest.fixtures.splice(idx, 1);
    if (!manifest.fixtureOnly) manifest.fixtureOnly = [];
    if (!manifest.fixtureOnly.includes(entry.fixture)) {
        manifest.fixtureOnly.push(entry.fixture);
    }
    console.log(`  ✓ Fixture moved to fixtureOnly — maintained manually from now on.`);
}

// ---------------------------------------------------------------------------
// ORPHAN handler
// ---------------------------------------------------------------------------
async function handleOrphan(manifest, entry) {
    console.log(`\n${"─".repeat(70)}`);
    console.log(`ORPHAN: ${entry.fixture}`);
    console.log(`  Stored source: ${entry.source} (no longer exists)`);

    const candidates = findCandidates(entry.fixture);
    const exactMatch = candidates.find(c => {
        try { return sha256(c) === entry.sourceHash; } catch { return false; }
    });

    // Build options list
    const options = [];
    const actions = [];

    if (exactMatch) {
        const rel = relFromTest(exactMatch);
        options.push(`Re-point to ${rel} (identical content — SHA matches)`);
        actions.push(() => updateSource(entry, rel));
    }

    const otherCandidates = candidates.filter(c => c !== exactMatch);
    otherCandidates.slice(0, 3).forEach(c => {
        const rel = relFromTest(c);
        options.push(`Re-point to ${rel} (content differs)`);
        actions.push(() => updateSource(entry, rel));
    });

    options.push("Break linkage — make fixture standalone (fixtureOnly)");
    actions.push(() => breakLinkage(manifest, entry));

    options.push("Skip — leave as-is (drift will continue to be reported)");
    actions.push(() => console.log("  – Skipped."));

    const choice = await menu("", options);
    actions[choice]();
}

// ---------------------------------------------------------------------------
// DRIFT handler
// ---------------------------------------------------------------------------
async function handleDrift(manifest, entry, currentHash, whitespaceOnly = false) {
    console.log(`\n${"─".repeat(70)}`);
    console.log(`DRIFT: ${entry.fixture}${whitespaceOnly ? "  [line-endings only]" : ""}`);
    console.log(`  Source:       ${entry.source}`);
    console.log(`  Stored hash:  ${entry.sourceHash}`);
    console.log(`  Current hash: ${currentHash}`);
    if (whitespaceOnly) {
        console.log(`  Note: content is identical after normalising line endings (CRLF ↔ LF).`);
    }

    const choice = await menu("How do you want to resolve this?", [
        "Accept change — re-sync fixture from source (copy content + update hash)",
        "Trivial change — update hash only (keep fixture content, note intentional divergence)",
        "Break linkage — make fixture standalone (fixtureOnly)",
        "Skip — leave as-is (drift will continue to be reported)",
    ]);

    if (choice === 0) syncFixture(entry);
    else if (choice === 1) updateHashOnly(entry);
    else if (choice === 2) breakLinkage(manifest, entry);
    else console.log("  – Skipped.");
}

// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
    console.log("Fixture Drift Resolver");
    console.log("======================");

    const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));

    // Collect issues in one pass so we can report a summary upfront
    const orphans = [];
    const drifts  = [];

    for (const entry of manifest.fixtures) {
        const sourceAbs = path.join(testBase, entry.source.replace(/\//g, path.sep));
        if (!fs.existsSync(sourceAbs)) {
            orphans.push(entry);
        } else {
            const current = sha256(sourceAbs);
            if (current !== entry.sourceHash) {
                // Check whether the difference is line-endings only by comparing
                // normalised (CR-stripped) hashes of the source and its fixture copy.
                const fixtureAbs = path.join(fixtureBase, entry.fixture.replace(/\//g, path.sep));
                const whitespaceOnly = fs.existsSync(fixtureAbs)
                    && sha256Normalized(sourceAbs) === sha256Normalized(fixtureAbs);
                drifts.push({ entry, current, whitespaceOnly });
            }
        }
    }

    const wsOnlyDrifts   = drifts.filter(d => d.whitespaceOnly);
    const contentDrifts  = drifts.filter(d => !d.whitespaceOnly);
    const total = orphans.length + drifts.length;

    if (total === 0) {
        console.log("\nNo fixture drift detected. All manifest entries are clean.\n");
        rl.close();
        return;
    }

    console.log(`\nFound ${orphans.length} orphan(s) and ${drifts.length} drift(s) to review.`);
    if (wsOnlyDrifts.length > 0) {
        console.log(`  ${wsOnlyDrifts.length} of those drift(s) are line-endings/whitespace only.`);
    }
    console.log();

    // Offer bulk resolution for whitespace-only drifts
    if (wsOnlyDrifts.length > 0) {
        console.log(`─`.repeat(70));
        console.log(`WHITESPACE-ONLY DRIFTS (${wsOnlyDrifts.length})`);
        console.log(`  These entries differ only in line endings (CRLF ↔ LF).`);
        console.log(`  Content is identical once whitespace is normalised.\n`);
        wsOnlyDrifts.forEach(d => console.log(`    ${d.entry.source}`));
        console.log();
        const bulkChoice = await menu("How do you want to resolve all whitespace-only drifts?", [
            `Accept all — re-sync ${wsOnlyDrifts.length} fixture(s) from source (copy content + update hash)`,
            `Update hashes only — keep fixture content, update stored hash to current source hash`,
            `Review individually — handle each one interactively`,
            `Skip all — leave as-is (drift will continue to be reported)`,
        ]);
        if (bulkChoice === 0) {
            for (const { entry } of wsOnlyDrifts) syncFixture(entry);
        } else if (bulkChoice === 1) {
            for (const { entry } of wsOnlyDrifts) updateHashOnly(entry);
        } else if (bulkChoice === 2) {
            for (const { entry, current } of wsOnlyDrifts) {
                await handleDrift(manifest, entry, current, true);
            }
        } else {
            console.log("  – All whitespace-only drifts skipped.");
        }
        console.log();
    }

    // Process a working copy of the manifest so we can splice correctly
    // while iterating. Orphans first (entries may be removed), then drifts.
    for (const entry of [...orphans]) {
        await handleOrphan(manifest, entry);
    }
    for (const { entry, current } of contentDrifts) {
        await handleDrift(manifest, entry, current);
    }

    // Persist updated manifest
    fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
    console.log(`\n${"─".repeat(70)}`);
    console.log("Manifest saved.");
    console.log("\nRun tests to confirm drift is resolved:");
    console.log("  npx jest test/unit/fixture-integrity");

    rl.close();
}

main().catch((err) => {
    console.error("Unexpected error:", err.message);
    rl.close();
    process.exit(1);
});
