#!/usr/bin/env node
"use strict";

// jso-symbolicate CLI — read an identifier map and a stack trace, write the
// demangled trace to stdout.
//
// Usage:
//   jso-symbolicate --map build-abc.map.json < stack.txt
//   jso-symbolicate --map build-abc.report.json --stack ./crash.log
//   cat stack.txt | jso-symbolicate --map global.txt --map member.txt
//   jso-symbolicate --map build-abc.report.json --event sentry --event-file event.json
//
// Map file formats accepted:
//   - JSO HTTP API report JSON ({ Report: { GlobalIdentifierMap, MemberIdentifierMap, ... } })
//   - The bare GlobalIdentifierMap or MemberIdentifierMap array
//   - An object of {mangled: original} pairs
//   - The "block" text form ("mangled=original\n...")

const fs = require("node:fs");
const path = require("node:path");
const { buildLookup, demangleStackWithStats } = require("../index.js");
const { SUPPORTED_EVENT_REPORTERS, symbolicateEvent } = require("../event.js");

function usage() {
    process.stderr.write([
        "jso-symbolicate — demangle obfuscated JS stack traces.",
        "",
        "Usage:",
        "  jso-symbolicate --map <file> [--map <file> ...] [--stack <file>] [--annotate] [--json]",
        "  jso-symbolicate --map <file> --event <reporter> [--event-file <file>] [--preserve-original]",
        "",
        "Options:",
        "  --map <file>     Identifier-map file. Pass multiple times to merge global+member.",
        "                   Accepts JSO API report JSON or the identifier-map block (text).",
        "  --stack <file>   Read the stack trace from this file instead of stdin.",
        "  --event <name>   Treat input as reporter event JSON and emit demangled JSON.",
        "                   Supported: " + SUPPORTED_EVENT_REPORTERS.join(", ") + ".",
        "  --event-file <file>",
        "                   Read reporter event JSON from this file instead of stdin.",
        "  --preserve-original",
        "                   For Sentry event mode, attach the mangled event under extra.",
        "  --annotate       Keep mangled names but append the original in a /* comment */.",
        "  --json           Stack mode: emit { stack, hits, perLine }. Event mode:",
        "                   emit { reporter, event, hits, mapEntries }.",
        "  --help           This message.",
        ""
    ].join("\n"));
}

function readMapFile(file) {
    const raw = fs.readFileSync(file, "utf8");
    const trimmed = raw.trim();
    if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
        let parsed;
        try { parsed = JSON.parse(raw); }
        catch (e) { throw new Error(`Map file ${file}: invalid JSON (${e.message})`); }

        // Dig into common report shapes.
        if (parsed && parsed.Report) {
            return [
                parsed.Report.GlobalIdentifierMap,
                parsed.Report.MemberIdentifierMap,
                parsed.Report.GlobalIdentifierMapBlock,
                parsed.Report.MemberIdentifierMapBlock
            ].filter(Boolean);
        }
        if (parsed && (parsed.GlobalIdentifierMap || parsed.MemberIdentifierMap)) {
            return [
                parsed.GlobalIdentifierMap,
                parsed.MemberIdentifierMap,
                parsed.GlobalIdentifierMapBlock,
                parsed.MemberIdentifierMapBlock
            ].filter(Boolean);
        }
        return [parsed];
    }
    // Block text form.
    return [raw];
}

function readStackInput(file) {
    if (file) return fs.readFileSync(file, "utf8");
    return new Promise((resolve, reject) => {
        const chunks = [];
        process.stdin.setEncoding("utf8");
        process.stdin.on("data", c => chunks.push(c));
        process.stdin.on("end", () => resolve(chunks.join("")));
        process.stdin.on("error", reject);
    });
}

function parseJsonInput(raw, label) {
    try {
        return JSON.parse(raw);
    } catch (e) {
        throw new Error(label + ": invalid JSON (" + e.message + ")");
    }
}

function countEventHits(before, after, lookup) {
    let total = 0;
    walkComparableStrings(before, after, function (left, right) {
        if (left !== right) total += countLookupTokens(left, lookup);
    });
    return total;
}

function walkComparableStrings(before, after, visit) {
    if (typeof before === "string") {
        if (typeof after === "string") visit(before, after);
        return;
    }
    if (!before || typeof before !== "object" || !after || typeof after !== "object") {
        return;
    }
    if (Array.isArray(before)) {
        for (let i = 0; i < before.length; i++) {
            walkComparableStrings(before[i], after[i], visit);
        }
        return;
    }
    for (const key of Object.keys(before)) {
        walkComparableStrings(before[key], after[key], visit);
    }
}

function countLookupTokens(text, lookup) {
    if (!text || !lookup || typeof lookup !== "object") return 0;
    let total = 0;
    for (const key of Object.keys(lookup)) {
        const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
        const matches = String(text).match(new RegExp("\\b" + escaped + "\\b", "g"));
        if (matches) total += matches.length;
    }
    return total;
}

async function main(argv) {
    const args = {
        maps: [],
        stack: null,
        annotate: false,
        json: false,
        event: null,
        eventFile: null,
        preserveOriginal: false,
    };
    for (let i = 2; i < argv.length; i++) {
        const a = argv[i];
        switch (a) {
            case "--help":
            case "-h": usage(); process.exit(0); break;
            case "--map": args.maps.push(argv[++i]); break;
            case "--stack": args.stack = argv[++i]; break;
            case "--event": args.event = argv[++i]; break;
            case "--event-file": args.eventFile = argv[++i]; break;
            case "--preserve-original": args.preserveOriginal = true; break;
            case "--annotate": args.annotate = true; break;
            case "--json": args.json = true; break;
            default:
                process.stderr.write(`jso-symbolicate: unknown argument: ${a}\n`);
                usage();
                process.exit(2);
        }
    }

    if (args.maps.length === 0) {
        process.stderr.write("jso-symbolicate: at least one --map <file> is required\n");
        usage();
        process.exit(2);
    }

    const sources = [];
    for (const m of args.maps) {
        if (!fs.existsSync(m)) {
            process.stderr.write(`jso-symbolicate: map file not found: ${m}\n`);
            process.exit(2);
        }
        for (const src of readMapFile(m)) sources.push(src);
    }

    const lookup = buildLookup(...sources);
    const lookupSize = Object.keys(lookup).length;
    if (lookupSize === 0) {
        process.stderr.write("jso-symbolicate: warning — map produced zero entries; check input format\n");
    }

    if (args.event) {
        if (args.stack) {
            throw new Error("--stack cannot be combined with --event; use --event-file for event JSON");
        }
        const rawEvent = await readStackInput(args.eventFile);
        const event = parseJsonInput(rawEvent, args.eventFile || "stdin");
        const beforeEvent = JSON.parse(JSON.stringify(event));
        const processed = symbolicateEvent(args.event, event, lookup, {
            preserveOriginal: args.preserveOriginal,
        });
        const hits = countEventHits(beforeEvent, processed, lookup);
        if (args.json) {
            process.stdout.write(JSON.stringify({
                reporter: args.event,
                event: processed,
                hits: hits,
                mapEntries: lookupSize,
            }, null, 2) + "\n");
        } else {
            process.stdout.write(JSON.stringify(processed, null, 2) + "\n");
            process.stderr.write(`jso-symbolicate: ${args.event} event processed with ${hits} substitution(s) across ${lookupSize} map entries\n`);
        }
        return;
    }

    const stack = await readStackInput(args.stack);
    const result = demangleStackWithStats(stack, lookup, { style: args.annotate ? "annotate" : "replace" });

    if (args.json) {
        process.stdout.write(JSON.stringify({
            stack: result.stack,
            hits: result.hits,
            mapEntries: lookupSize,
            perLine: result.perLine
        }, null, 2) + "\n");
    } else {
        process.stdout.write(result.stack);
        if (!result.stack.endsWith("\n")) process.stdout.write("\n");
        process.stderr.write(`jso-symbolicate: ${result.hits} substitution(s) across ${lookupSize} map entries\n`);
    }
}

main(process.argv).catch(err => {
    process.stderr.write(`jso-symbolicate: ${err.message}\n`);
    process.exit(1);
});
