// @ts-check
import { createWriteStream, existsSync, mkdirSync, readdirSync, rmSync, statSync, write } from "fs";

const filename_timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const debug = false;

// #region public api

/**
 * @typedef {"server" | "client" | "client-http"} ProcessType
 */

let originalConsoleLog = console.log;
let originalConsoleError = console.error;
let originalConsoleWarn = console.warn;
let originalConsoleInfo = console.info;
let originalConsoleDebug = console.debug;
let didPatch = false;
/** @type {(() => void) | null} */
let unpatchFunction = null;

const buildOutputState = {
    mode: "serve",
    progressActive: false,
    assetFiles: 0,
    assetTotalKb: 0,
    assetGzipKb: 0,
    compressionFiles: 0,
    compressionTotalKb: 0,
    compressionGzipKb: 0,
    compressionCaptureActive: false,
};

/**
 * Parses a numeric kB value from a potentially formatted string (e.g. "1.23 kB"),
 * stripping all non-numeric characters except the decimal point.
 * Returns 0 for any value that cannot be parsed as a finite number.
 * @param {unknown} value @returns {number}
 */
function parseKb(value) {
    const numeric = Number.parseFloat(String(value ?? "0").replace(/[^0-9.]/g, ""));
    return Number.isFinite(numeric) ? numeric : 0;
}

/**
 * Clears the current in-place progress line from the terminal, if one is active.
 * Has no effect in non-TTY environments (CI, piped output).
 */
function clearBuildProgressLine() {
    if (!process.stdout.isTTY || !buildOutputState.progressActive) return;
    process.stdout.write("\r\x1b[2K");
    buildOutputState.progressActive = false;
}

/**
 * Overwrites the current terminal line with a transient build progress indicator.
 * Subsequent calls overwrite the same line in place, keeping the output clean.
 * Has no effect in non-TTY environments (CI, piped output).
 * @param {string} text
 */
function writeBuildProgressLine(text) {
    if (!process.stdout.isTTY) return;
    const maxLength = Math.max(24, (process.stdout.columns || 120) - 1);
    const line = text.length > maxLength ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text;
    process.stdout.write(`\r\x1b[2K${line}\x1b[0K`);
    buildOutputState.progressActive = true;
}

/**
 * Resets all per-build asset and compression counters back to zero.
 * Called at the start of each build and after summaries are flushed.
 */
function resetBuildOutputState() {
    buildOutputState.progressActive = false;
    buildOutputState.assetFiles = 0;
    buildOutputState.assetTotalKb = 0;
    buildOutputState.assetGzipKb = 0;
    buildOutputState.compressionFiles = 0;
    buildOutputState.compressionTotalKb = 0;
    buildOutputState.compressionGzipKb = 0;
    buildOutputState.compressionCaptureActive = false;
}

/**
 * Emits aggregated bundle/compression summary lines to stdout, then resets counters.
 *
 * During a build, Vite normally prints one line per output file (e.g.
 * "dist/foo.js  1.23 kB │ gzip: 0.45 kB"). Those lines are intercepted by
 * tryHandleBuildConsoleOutput, which silently accumulates file counts and sizes
 * instead of printing them. This function is called once bundling finishes (when
 * Vite emits "✓ built in …") and replaces all the per-file lines with a single
 * formatted summary like "[needle-buildinfo]\n✓ Bundled 27 files (1234.56 kB)".
 */
function flushBuildOutputSummaries() {
    if (buildOutputState.mode !== "build") return;
    clearBuildProgressLine();

    if (buildOutputState.assetFiles > 0) {
        const msg = formatBuildInfoSummaryMessage(`✓ Bundled ${buildOutputState.assetFiles} files (${buildOutputState.assetTotalKb.toFixed(2)} kB${buildOutputState.assetGzipKb > 0 ? `, gzip ${buildOutputState.assetGzipKb.toFixed(2)} kB` : ""})`);
        originalConsoleLog(msg);
        captureLogMessage("server", "log", msg, null);
    }

    if (buildOutputState.compressionFiles > 0) {
        const msg = formatBuildInfoSummaryMessage(`✓ Gzip compressed ${buildOutputState.compressionFiles} files (${buildOutputState.compressionTotalKb.toFixed(2)} kB → ${buildOutputState.compressionGzipKb.toFixed(2)} kB)`);
        originalConsoleLog(msg);
        captureLogMessage("server", "log", msg, null);
    }

    resetBuildOutputState();
}

/**
 * Returns true if stdout is a TTY and the NO_COLOR environment variable is not set.
 * Used to guard ANSI escape sequences so output stays clean in CI and piped contexts.
 */
function supportsColorOutput() {
    return !!process.stdout?.isTTY && process.env.NO_COLOR !== "1";
}

/**
 * Formats a plugin name as a styled [header] string.
 * Names starting with "needle-" or "needle:" get ANSI green coloring with a bold suffix.
 * Falls back to plain "[name]" when color output is not supported.
 * @param {string} name @returns {string}
 */
function formatNeedleHeader(name) {
    if (!supportsColorOutput()) return `[${name}]`;
    if (name.startsWith("needle-")) {
        const suffix = name.substring("needle-".length);
        return `\x1b[32m[\x1b[0m\x1b[32mneedle-\x1b[0m\x1b[1;32m${suffix}\x1b[0m\x1b[32m]\x1b[0m`;
    }
    if (name.startsWith("needle:")) {
        const suffix = name.substring("needle:".length);
        return `\x1b[32m[\x1b[0m\x1b[32mneedle:\x1b[0m\x1b[1;32m${suffix}\x1b[0m\x1b[32m]\x1b[0m`;
    }
    return `\x1b[32m[\x1b[0m\x1b[1;32m${name}\x1b[0m\x1b[32m]\x1b[0m`;
}

/**
 * Wraps a build summary string under the [needle-buildinfo] header.
 * Used by flushBuildOutputSummaries to format the final "✓ Bundled N files"
 * and "✓ Gzip compressed N files" lines.
 * @param {string} body @returns {string}
 */
function formatBuildInfoSummaryMessage(body) {
    return `${formatNeedleHeader("needle-buildinfo")}\n${body}`;
}

/**
 * Joins an array of console.log arguments into a single string,
 * serializing non-string values with stringifyLog.
 * @param {unknown[]} args @returns {string}
 */
function normalizeConsoleArgs(args) {
    return args.map(arg => typeof arg === "string" ? arg : stringifyLog(arg)).join(" ");
}

/**
 * Intercepts console output during a build run and decides whether to suppress or forward it.
 *
 * - Per-file asset lines ("dist/foo.js  1.23 kB") are silently accumulated into
 *   buildOutputState counters and replaced by a summary via flushBuildOutputSummaries.
 * - vite-plugin-compression per-file lines are similarly accumulated.
 * - "transforming (N)" progress messages are rendered as an animated in-place indicator.
 * - Any other message (including "✓ built in …") triggers flushBuildOutputSummaries
 *   before passing through.
 *
 * Returns true if the message was fully consumed (caller must not forward to original
 * console), or false if it should pass through normally.
 * @param {unknown[]} args @returns {boolean}
 */
function tryHandleBuildConsoleOutput(args) {
    if (buildOutputState.mode !== "build") return false;

    const raw = normalizeConsoleArgs(args);
    const message = raw.replace(/\u001b\[[0-9;]*m/g, "").trim();
    if (!message.length) return true;

    if (/^transforming\s*\(/i.test(message)) {
        writeBuildProgressLine(`⏳ ${message}`);
        return true;
    }

    if (message.includes("[vite-plugin-compression]:algorithm=")) {
        buildOutputState.compressionCaptureActive = true;
        return true;
    }

    const assetMatch = message.match(/^dist\/.*?\s+([0-9.,]+)\s*kB(?:\s*[│|]\s*gzip:\s*([0-9.,]+)\s*kB)?$/i);
    if (assetMatch) {
        buildOutputState.assetFiles++;
        buildOutputState.assetTotalKb += parseKb(assetMatch[1]);
        if (assetMatch[2]) buildOutputState.assetGzipKb += parseKb(assetMatch[2]);
        writeBuildProgressLine(`📦 Bundling assets: ${buildOutputState.assetFiles} files`);
        return true;
    }

    if (buildOutputState.compressionCaptureActive) {
        const compressionMatch = message.match(/^dist\/.*?\s+([0-9.]+)\s*kb\s*\/\s*gzip:\s*([0-9.]+)\s*kb$/i);
        if (compressionMatch) {
            buildOutputState.compressionFiles++;
            buildOutputState.compressionTotalKb += parseKb(compressionMatch[1]);
            buildOutputState.compressionGzipKb += parseKb(compressionMatch[2]);
            writeBuildProgressLine(`🗜️ Compressing: ${buildOutputState.compressionFiles} files`);
            return true;
        }
    }

    if (message.startsWith("✓ built in")) {
        flushBuildOutputSummaries();
        return false;
    }

    flushBuildOutputSummaries();
    return false;
}

/**
 * Patches the global console methods to intercept and process Vite build output.
 *
 * In build mode, per-file asset lines emitted by Vite (and vite-plugin-compression)
 * are absorbed silently, counts and sizes are accumulated, and a concise summary is
 * printed by flushBuildOutputSummaries once bundling completes. In serve mode all
 * messages pass through unchanged.
 *
 * All console output (log/error/warn/info/debug) is also written to a rotating
 * timestamped log file under node_modules/.needle/logs/ via captureLogMessage.
 *
 * Calling patchConsoleLogs a second time is a no-op and returns the same unpatch
 * function. Call the returned function to restore the original console methods and
 * flush any pending summaries.
 *
 * @param {{command?: string} | undefined} [options]
 * @returns {(() => void) | null}
 */
export function patchConsoleLogs(options = undefined) {
    if (didPatch) return unpatchFunction;
    didPatch = true;
    buildOutputState.mode = options?.command === "build" ? "build" : "serve";
    resetBuildOutputState();

    console.log = (...args) => {
        if (tryHandleBuildConsoleOutput(args)) return;
        originalConsoleLog(...args);
        captureLogMessage("server", 'log', args, null);
    };
    console.error = (...args) => {
        flushBuildOutputSummaries();
        originalConsoleError(...args);
        captureLogMessage("server", 'error', args, null);
    };
    console.warn = (...args) => {
        flushBuildOutputSummaries();
        originalConsoleWarn(...args);
        captureLogMessage("server", 'warn', args, null);
    };
    console.info = (...args) => {
        if (tryHandleBuildConsoleOutput(args)) return;
        originalConsoleInfo(...args);
        captureLogMessage("server", 'info', args, null);
    };
    console.debug = (...args) => {
        if (tryHandleBuildConsoleOutput(args)) return;
        originalConsoleDebug(...args);
        captureLogMessage("server", 'debug', args, null);
    };

    // Restore original console methods
    unpatchFunction = () => {
        flushBuildOutputSummaries();
        didPatch = false;
        console.log = originalConsoleLog;
        console.error = originalConsoleError;
        console.warn = originalConsoleWarn;
        console.info = originalConsoleInfo;
        console.debug = originalConsoleDebug;
        buildOutputState.mode = "serve";
    }
    return unpatchFunction;
}


let isCapturing = false;
/** @type {Set<unknown>} */
const isCapturingLogMessage = new Set();

/** @type {Array<{ process: ProcessType, key: string, log:unknown, timestamp:number, connectionId: string | null }>} */
const queue = new Array();

/**
 * Serializes a log message and appends it to the rotating per-process log file
 * (node_modules/.needle/logs/<timestamp>.server.needle.log).
 *
 * Re-entrant calls while a capture is already in progress are queued and
 * processed afterwards to prevent recursive logging loops. Circular references
 * in the log value are also guarded against via the isCapturingLogMessage set.
 *
 * @param {ProcessType} process
 * @param {string} key
 * @param {unknown} log
 * @param {string | null} connectionId - Optional connection ID for client logs.
 * @param {number} [time] - Optional timestamp, defaults to current time.
 */
export function captureLogMessage(process, key, log, connectionId, time = Date.now()) {

    if (isCapturingLogMessage.has(log)) {
        return; // prevent circular logs
    }

    if (isCapturing) {
        queue.push({ process, key, log, timestamp: Date.now(), connectionId });
        return;
    }
    isCapturing = true;
    isCapturingLogMessage.add(log);

    try {
        let str = /** @type {string} */(stringifyLog(log));
        if (str.trim().length > 0) {
            // if(process === "server") str = stripAnsiColors(str);
            const prefix = `${getTimestamp(time, true)}, ${process}${connectionId ? (`[${connectionId}]`) : ""}.${key}: `;
            const separator = "";
            const finalLog = indent(`${prefix}${separator}${removeEmptyLinesAtStart(str)}`, prefix.length, separator)
            writeToFile(process, finalLog, connectionId);
        }
    } finally {
        isCapturing = false;
        isCapturingLogMessage.delete(log);
    }

    let queued = queue.pop();
    if (queued) {
        captureLogMessage(queued.process, queued.key, queued.log, queued.connectionId, queued.timestamp);
    }
}


// #region stringify log



/**
 * Recursively serializes an arbitrary value to a human-readable string.
 * Handles primitives, plain objects, arrays, typed arrays, Error instances,
 * circular references, and truncates large values based on per-environment limits
 * (server limits are more generous than browser limits).
 * @param {unknown} log
 * @param {Set<unknown>} [seen]
 */
function stringifyLog(log, seen = /** @type {Set<unknown>} */ (new Set()), depth = 0) {
    const isServer = typeof window === "undefined";
    const stringify_limits = {
        string: isServer ? 100_000 : 1_000,
        object_keys: isServer ? 300 : 200,
        object_depth: isServer ? 10 : 3,
        array_items: isServer ? 2_000 : 100,
    }

    if (typeof log === "string") {
        if (log.length > stringify_limits.string) log = `${log.slice(0, stringify_limits.string)}... <truncated ${log.length - stringify_limits.string} characters>`;
        return log;
    }
    if (typeof log === "number" || typeof log === "boolean") {
        return String(log);
    }
    if (log === null) {
        return "null";
    }
    if (log === undefined) {
        return "undefined";
    }
    if (typeof log === "function") {
        return "<function>";
    }

    if (seen.has(log)) return "<circular>";

    if (Array.isArray(log)
        || log instanceof ArrayBuffer
        || log instanceof Uint8Array
        || log instanceof Float32Array
        || log instanceof Int32Array
        || log instanceof Uint32Array
        || log instanceof Uint16Array
        || log instanceof Uint8ClampedArray
        || log instanceof Int16Array
        || log instanceof Int8Array
        || log instanceof BigInt64Array
        || log instanceof BigUint64Array
        || log instanceof Float64Array
    ) {
        const logArr = /** @type {ArrayLike<unknown>} */ (/** @type {unknown} */ (log));
        seen.add(logArr);
        return stringifyArray(logArr);
    }
    if (typeof log === "object") {

        if (depth > stringify_limits.object_depth) {
            return "<object too deep>";
        }

        seen.add(log);

        if(log instanceof Error) {
            return `<Error: ${log.message}\nStack: ${log.stack}>`;
        }

        const logObj = /** @type {Record<string, unknown>} */ (log);
        const keys = Object.keys(logObj);
        let res = "{";
        for (let i = 0; i < keys.length; i++) {
            const key = keys[i];
            let value = logObj[key];
            if (i >= stringify_limits.object_keys) {
                res += `, ... <truncated ${keys.length - i} keys>`;
                break;
            }

            if (typeof value === "number") {
                // clamp precision for numbers if it has decimal places
                if (value % 1 !== 0) {
                    value = Number(value.toFixed(4));
                }
            }
            let str = stringifyLog(value, seen, depth + 1);
            if (typeof value === "object") {
                if (Array.isArray(value)) {
                    str = `[${str}]`;
                }
            }
            else if (typeof value === "string") {
                str = `"${str}"`;
            }
            if (i > 0) res += ", ";
            res += `"${key}":${str}`;
        }
        res += "}";
        return res;
        // let entries = Object.entries(log).map(([key, value], index) => {
        //     if (index > stringify_limits.object_keys) return `"${key}": <truncated>`;
        //     return `"${key}": ${stringifyLog(value, seen, depth + 1)}`;
        // });
        // return `{ ${entries.join(", ")} }`;
    }

    return String(log);

    /** @param {ArrayLike<unknown>} arr @returns {string} */
    function stringifyArray(arr) {
        let res = "";
        for (let i = 0; i < arr.length; i++) {
            let entry = arr[i];
            if (res && i > 0) res += ", ";
            if (i > stringify_limits.array_items) {
                res += "<truncated " + (arr.length - i) + ">";
                break;
            }
            res += stringifyLog(entry, seen, depth + 1);
        }
        return res;
    }
}








// #region utility functions

/**
 * Returns the current time as a HH:MM:SS string (timeOnly=true) or a full ISO 8601
 * timestamp string. Used as a prefix in log file entries.
 * @param {number} [date] - Optional timestamp in ms, defaults to now.
 * @param {boolean} [timeOnly]
 */
function getTimestamp(date, timeOnly = false) {
    const now = date ? new Date(date) : new Date();
    if (timeOnly) {
        return now.toTimeString().split(' ')[0]; // HH:MM:SS
    }
    return now.toISOString();
}


/**
 * Indents a string by a specified length.
 * @param {string} str - The string to indent.
 * @param {number} length - The number of spaces to indent each line.
 * @returns {string} The indented string.
 */
function indent(str, length, separator = "") {
    const lines = str.split("\n");
    const prefixStr = " ".repeat(length) + separator;
    for (let i = 1; i < lines.length; i++) {
        let entry = lines[i].trim();
        if (entry.length === 0) continue; // skip empty lines
        // indent the line
        lines[i] = prefixStr + entry;
    }
    return lines.join("\n");
}

/**
 * Removes empty lines at the start of a string.
 * @param {string} str - The string to process.
 */
function removeEmptyLinesAtStart(str) {
    const lines = str.split("\n");
    for (let i = 0; i < lines.length; i++) {
        const line = lines[i].trim();
        if (line.length > 0) {
            lines[i] = line; // keep the first non-empty line
            return lines.slice(i).join("\n");
        }
    }
    return "";
}

/**
 * Strips ANSI color codes from a string.
 * @param {string} str - The string to process.
 */
function stripAnsiColors(str) {
    // This pattern catches most ANSI escape sequences
    return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
}


// #region log to file

/** @type {Map<string, import("fs").WriteStream>} */
const filestreams = new Map();
const fileLogDirectory = "node_modules/.needle/logs";
// cleanup old log files
if (existsSync(fileLogDirectory)) {
    const files = readdirSync(fileLogDirectory);
    // sort by age and keep the last 10 files
    files.sort((a, b) => {
        const aStat = statSync(`${fileLogDirectory}/${a}`);
        const bStat = statSync(`${fileLogDirectory}/${b}`);
        return aStat.mtimeMs - bStat.mtimeMs;
    });
    // remove all but the last 30 files
    const filesToKeep = 30;
    for (let i = 0; i < files.length - filesToKeep; i++) {
        rmSync(`${fileLogDirectory}/${files[i]}`, { force: true });
    }
}

/**
 * Appends a single log entry to the per-process rotating file log.
 * The file is created lazily under node_modules/.needle/logs/ on first write,
 * using a timestamped filename so each process start gets a fresh file.
 * @param {ProcessType} process
 * @param {string} log
 * @param {string | null} _connectionId - Reserved for future per-connection log files.
 */
function writeToFile(process, log, _connectionId) {
    const filename = `${process}.needle.log`; //connectionId && process === "client" ? `${process}-${connectionId}.needle.log` : `${process}.needle.log`;

    if (!filestreams.has(filename)) {
        if (!existsSync(fileLogDirectory)) {
            mkdirSync(fileLogDirectory, { recursive: true });
        }
        filestreams.set(filename, createWriteStream(`${fileLogDirectory}/${filename_timestamp}.${filename}`, { flags: 'a' }));
    }
    const writeStream = filestreams.get(filename);
    if (!writeStream) {
        if (debug) console.error(`No write stream for process: ${filename}`);
        return;
    }
    writeStream.write(log + '\n');
}



// #region process exit

/** Flushes and closes all open log write streams. Registered for all exit-related process events. */
function onExit() {
    filestreams.forEach((stream) => stream.end());
    filestreams.clear();
}

/** Explicitly close all log file streams. Call this before a clean process exit (e.g. end of a build run). */
export function closeLogStreams() {
    onExit();
}

const events = ['SIGTERM', 'SIGINT', 'beforeExit', 'rejectionHandled', 'uncaughtException', 'exit'];
for (const event of events) {
    process.on(event, onExit);
}