import {EditorPlugin, ui, workspace, tools, data} from '@wonderlandengine/editor-api';
import fs from 'fs';
import path from 'path';
import util from 'util';

import * as zapworksCli from '@zappar/zapworks-cli';

// Zappar logo (GitHub org avatar) as base64. (Source: https://github.com/zappar-xr.png)
const ZAPPAR_LOGO_JPEG_BASE64 =
    ' ';

const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']);

function splitArgs(str) {
    if (!str) return [];
    const args = [];
    const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
    let match;
    while ((match = re.exec(str))) args.push(match[1] ?? match[2] ?? match[3]);
    return args;
}

function stripOutputArgs(args) {
    const out = [];
    for (let i = 0; i < args.length; i++) {
        const a = args[i];
        if (a === '-o' || a === '--output') {
            i++;
            continue;
        }
        out.push(a);
    }
    return out;
}

function resolveWorkspacePath(maybeRelativePath) {
    if (!maybeRelativePath) return '';
    return path.isAbsolute(maybeRelativePath)
        ? maybeRelativePath
        : path.join(workspace.root, maybeRelativePath);
}

function isImageFile(filePath) {
    return IMAGE_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}

export default class ZapparPublishPlugin extends EditorPlugin {
    constructor() {
        super();
        this.name = 'Zappar Publish Plugin';

        this._logoImage = null;

        this._projectId = '';
        this._version = '1.0.0';
        this._publishing = false;

        this._trainInputPath = '';
        this._trainExtraArgs = '';
        this._training = false;
        this._trainProgress = '';

        this._status = '';

        /** Last uploaded ZapWorks URL (if present in CLI output). */
        this.projectURL = '';

        this._targetsFolder = path.join(workspace.root, 'static', 'zappar-targets');

        ui.loadImage(Buffer.from(ZAPPAR_LOGO_JPEG_BASE64, 'base64'))
            .then((img) => (this._logoImage = img))
            .catch((e) => console.warn('Failed to load Zappar logo:', e));

        /* Load the config here, so that settings don't get lost on reloads */
        this._loadConfig().catch((e) => this._setError(e, 'Failed to load Zappar config'));
    }

    draw() {
        if (this._logoImage) ui.image(this._logoImage, 96, 96);
        ui.text('Publish to ZapWorks (Zappar)');

        ui.separator();
        this._drawPublishSection();

        ui.separator();
        this._drawTrainSection();

        if (this._status) {
            ui.separator();
            ui.text(this._status);
        }
    }

    _drawPublishSection() {
        ui.text('Publish');

        const projectId = ui.inputText('ZapWorks Project ID', this._projectId);
        if (projectId !== null) this._projectId = projectId;
        const version = ui.inputText('Version', this._version);
        if (version !== null) this._version = version;

        const canRun = !!this._projectId && !!this._version;
        if (!canRun) {
            ui.text('Enter Project ID + Version to enable publish actions.');
        }

        if (this._publishing) {
            ui.text('Publishing...');
            ui.spinner();
            return;
        }

        if (ui.button('Upload')) this._upload({alsoPublish: false});
        ui.sameLine();
        if (ui.button('Publish')) this._publishOnly();
        ui.sameLine();
        if (ui.button('Upload & Publish')) this._upload({alsoPublish: true});

        if (this.projectURL) {
            ui.text(`Uploaded to: ${this.projectURL}`);
            ui.sameLine();
            if (ui.button('Open')) {
                tools.openBrowser(this.projectURL);
            }
        }
    }

    _drawTrainSection() {
        ui.text('Train image targets');
        ui.text(`Outputs to: ${this._targetsFolder}`);

        const inputPath = ui.inputText('Image file or folder path', this._trainInputPath);
        if (inputPath !== null) this._trainInputPath = inputPath;

        const extra = ui.inputText('Extra train args (optional)', this._trainExtraArgs);
        if (extra !== null) this._trainExtraArgs = extra;

        if (this._training) {
            ui.label(this._trainProgress || 'Training...');
            ui.spinner();
            return;
        }

        if (ui.button('Train Targets')) this._trainTargets();
    }

    async _packageProject() {
        this._status = 'Packaging project...';
        await tools.packageProject();
        this._status = 'Packaging complete.';
    }

    async _runZapworks(args, {label, sendEnter} = {}) {
        const argv = ['node', 'zapworks', '-y', ...args];

        const originalArgv = process.argv;
        const originalCwd = process.cwd;
        const originalExit = process.exit;
        const originalStdoutWrite = process.stdout.write.bind(process.stdout);
        const originalStderrWrite = process.stderr.write.bind(process.stderr);
        const originalConsole = {
            log: console.log,
            info: console.info,
            warn: console.warn,
            error: console.error,
        };

        const restore = [];
        const addRestore = (fn) => restore.push(fn);

        const stripAnsi = (s) => {
            const text = String(s);
            // OSC (Operating System Command): ESC ] ... BEL or ESC \
            const withoutOsc = text.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '');
            // CSI + other control sequences (handles ESC[... and 0x9B ...)
            return withoutOsc.replace(
                /[\x1B\x9B][[\]()#;?]*(?:\d{1,4}(?:;\d{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
                ''
            );
        };

        let buf = '';
        let lastOutputAt = Date.now();
        let suppressWriteCapture = false;
        let exitCode;

        const onChunk = (chunk) => {
            const text =
                typeof chunk === 'string'
                    ? chunk
                    : (chunk?.toString?.('utf8') ?? String(chunk));
            const clean = stripAnsi(text);
            buf += clean;
            lastOutputAt = Date.now();

            if (label) {
                const statusText = clean.replace(/■/g, '=');
                const trimmed = statusText.trim();
                if (trimmed) this._status = `${label}: ${trimmed}`;
            }
        };

        const waitForQuietOutput = async ({idleMs = 100, maxMs = 5000} = {}) => {
            const start = Date.now();
            while (Date.now() - start < maxMs) {
                if (Date.now() - lastOutputAt >= idleMs) return;
                await new Promise((r) => setTimeout(r, 50));
            }
        };

        const wrapWrite = (originalWrite) => (chunk, encoding, cb) => {
            let enc = encoding;
            let callback = cb;
            if (typeof enc === 'function') {
                callback = enc;
                enc = undefined;
            }

            if (!suppressWriteCapture) onChunk(chunk);
            return originalWrite(chunk, enc, callback);
        };

        const wrapConsoleMethod = (methodName) => {
            const original = originalConsole[methodName];
            return (...args2) => {
                try {
                    onChunk(`${util.format(...args2)}\n`);
                } catch {
                    /* noop */
                }

                // Avoid double-capture when console.* ultimately writes through
                // process.stdout/process.stderr (which we also wrap).
                suppressWriteCapture = true;
                try {
                    return original.apply(console, args2);
                } finally {
                    suppressWriteCapture = false;
                }
            };
        };

        try {
            process.argv = argv;
            addRestore(() => (process.argv = originalArgv));
            process.cwd = () => workspace.root;
            addRestore(() => (process.cwd = originalCwd));

            if (sendEnter) {
                try {
                    process.stdin?.resume?.();
                    setTimeout(() => {
                        try {
                            process.stdin?.emit?.('data', Buffer.from('\n'));
                        } catch {
                            /* noop */
                        }
                    }, 0);
                } catch {
                    /* noop */
                }
            }

            process.exit = (code = 0) => {
                exitCode = code;
                return undefined;
            };
            addRestore(() => (process.exit = originalExit));

            process.stdout.write = wrapWrite(originalStdoutWrite);
            process.stderr.write = wrapWrite(originalStderrWrite);
            addRestore(() => (process.stdout.write = originalStdoutWrite));
            addRestore(() => (process.stderr.write = originalStderrWrite));

            // Some libraries (or a Console instance created earlier) may hold a cached
            // reference to the original stdout writer, which would bypass our
            // process.stdout/process.stderr write overrides. Wrap console methods so we
            // still capture lines like "Success … Project uploaded to: …".
            console.log = wrapConsoleMethod('log');
            console.info = wrapConsoleMethod('info');
            console.warn = wrapConsoleMethod('warn');
            console.error = wrapConsoleMethod('error');
            addRestore(() => (console.log = originalConsole.log));
            addRestore(() => (console.info = originalConsole.info));
            addRestore(() => (console.warn = originalConsole.warn));
            addRestore(() => (console.error = originalConsole.error));

            const result = zapworksCli.cli(argv);
            if (result && typeof result.then === 'function') await result;

            // Capture output that may arrive shortly after cli() resolves/returns.
            await waitForQuietOutput();

            if (typeof exitCode === 'number' && exitCode !== 0) {
                const err = new Error(`zapworks exited (${exitCode})`);
                err.__zapworksExitCode = exitCode;
                throw err;
            }

            return buf;
        } finally {
            // Restore in reverse order of application.
            while (restore.length) {
                const fn = restore.pop();
                try {
                    fn();
                } catch {
                    /* noop */
                }
            }
        }
    }

    _extractProjectUrl(logText) {
        if (!logText) return '';
        const re = /Project uploaded to:\s*(https?:\/\/[^\s]+)/g;
        let match;
        let lastUrl = '';
        while ((match = re.exec(logText))) {
            lastUrl = match[1];
        }

        return (lastUrl || '').replace(/[)\]}>.,;]+$/g, '');
    }

    _getDeployDirArg() {
        return path.relative(workspace.root, workspace.deployPath);
    }

    _buildPublishArgs({includeDir = false} = {}) {
        const args = ['publish', '--version', this._version, '--project', this._projectId];
        if (includeDir) {
            args.push('--dir', this._getDeployDirArg());
        }
        if (data.settings.editor.serverCOEP !== 'unsafe-none') {
            args.push('--cross-origin-isolation');
        }
        return args;
    }

    async _publish({includeDir = false, sendEnter = false} = {}) {
        const args = this._buildPublishArgs({includeDir});
        await this._runZapworks(args, {label: 'Publishing', sendEnter});
    }

    _upload({alsoPublish}) {
        if (this._publishing) return;
        if (!this._projectId || !this._version) {
            this._status = 'Missing Project ID or Version.';
            return;
        }

        this._publishing = true;
        (async () => {
            await this._packageProject();
            const args = [
                'upload',
                '--version',
                this._version,
                '--project',
                this._projectId,
                '--dir',
                this._getDeployDirArg(),
            ];
            if (data.settings.editor.serverCOEP !== 'unsafe-none') {
                args.push('--cross-origin-isolation');
            }
            const uploadLog = await this._runZapworks(args, {label: 'Uploading'});
            const url = this._extractProjectUrl(uploadLog);
            if (url) this.projectURL = url;

            if (alsoPublish) {
                await this._publish({includeDir: true});
            }

            this._status = alsoPublish ? 'Upload & publish complete.' : 'Upload complete.';
        })()
            .catch((e) => this._setError(e, 'Upload/publish failed'))
            .finally(() => (this._publishing = false));
    }

    _publishOnly() {
        if (this._publishing) return;
        if (!this._projectId || !this._version) {
            this._status = 'Missing Project ID or Version.';
            return;
        }

        this._publishing = true;
        (async () => {
            await this._packageProject();
            await this._publish({sendEnter: true});
            this._status = 'Publish complete.';
        })()
            .catch((e) => this._setError(e, 'Publish failed'))
            .finally(() => (this._publishing = false));
    }

    async _collectImages(input) {
        const resolved = resolveWorkspacePath(input);
        const stat = await fs.promises.stat(resolved);

        if (stat.isDirectory()) {
            const entries = await fs.promises.readdir(resolved, {withFileTypes: true});
            return entries
                .filter((e) => e.isFile())
                .map((e) => path.join(resolved, e.name))
                .filter(isImageFile);
        }

        return [resolved];
    }

    _trainTargets() {
        if (this._training) return;
        if (!this._trainInputPath) {
            this._status = 'Provide an image path or folder path.';
            return;
        }

        this._training = true;
        this._trainProgress = '';

        (async () => {
            await fs.promises.mkdir(this._targetsFolder, {recursive: true});
            const images = await this._collectImages(this._trainInputPath);
            if (!images.length) {
                this._status = 'No .png/.jpg/.jpeg files found.';
                return;
            }

            const extraArgs = stripOutputArgs(splitArgs(this._trainExtraArgs));
            let ok = 0;
            let fail = 0;

            for (const img of images) {
                const base = path.parse(img).name;
                const out = path.join(this._targetsFolder, `${base}.zpt`);
                this._trainProgress = `Training ${path.basename(img)}...`;
                try {
                    await this._runZapworks(['train', img, ...extraArgs, '-o', out], {
                        label: 'Training',
                    });
                    ok++;
                } catch (e) {
                    console.error(e);
                    fail++;
                }
            }

            this._status = `Training complete. Success: ${ok}, Failed: ${fail}`;
        })()
            .catch((e) => this._setError(e, 'Training failed'))
            .finally(() => {
                this._training = false;
                this._trainProgress = '';
            });
    }

    _setError(error, prefix) {
        const msg = error?.message ?? String(error);
        this._status = prefix ? `${prefix}: ${msg}` : msg;
        console.error(error);
    }

    _sanitizeFileName(name) {
        if (!name) return 'project';
        return String(name).replace(/[\\/:*?"<>|]/g, '_');
    }

    _getConfigFilePath() {
        const projectName = this._sanitizeFileName(data?.settings?.project?.name);
        return path.join(workspace.root, `${projectName}.zappar.config.json`);
    }

    _toRelativeIfPossible(absolutePath) {
        if (!absolutePath) return '';
        const rel = path.relative(workspace.root, absolutePath);
        if (!rel || rel === '.') return '.';
        if (rel.startsWith('..') || path.isAbsolute(rel)) return absolutePath;
        return rel;
    }

    _fromMaybeRelative(maybeRelativePath) {
        if (!maybeRelativePath) return '';
        return path.isAbsolute(maybeRelativePath)
            ? maybeRelativePath
            : path.join(workspace.root, maybeRelativePath);
    }

    async _saveConfig() {
        const configPath = this._getConfigFilePath();
        const payload = {
            schemaVersion: 1,
            projectId: this._projectId,
            version: this._version,
            trainExtraArgs: this._trainExtraArgs,
            targetsFolder: this._toRelativeIfPossible(this._targetsFolder),
            projectURL: this.projectURL,
        };
        await fs.promises.writeFile(configPath, JSON.stringify(payload, null, 2), 'utf8');
    }

    async _loadConfig() {
        const configPath = this._getConfigFilePath();

        let text;
        try {
            text = await fs.promises.readFile(configPath, 'utf8');
        } catch (e) {
            if (e && typeof e === 'object' && e.code === 'ENOENT') return;
            throw e;
        }

        let parsed;
        try {
            parsed = JSON.parse(text);
        } catch (e) {
            throw new Error(`Invalid JSON in ${path.basename(configPath)}`);
        }

        if (parsed && typeof parsed === 'object') {
            if (typeof parsed.projectId === 'string') this._projectId = parsed.projectId;
            if (typeof parsed.version === 'string') this._version = parsed.version;
            if (typeof parsed.trainExtraArgs === 'string')
                this._trainExtraArgs = parsed.trainExtraArgs;
            if (typeof parsed.projectURL === 'string') this.projectURL = parsed.projectURL;
            if (typeof parsed.targetsFolder === 'string') {
                const resolved = this._fromMaybeRelative(parsed.targetsFolder);
                if (resolved) this._targetsFolder = resolved;
            }
        }
    }

    preProjectSave() {
        this._saveConfig().catch((e) => this._setError(e, 'Failed to save Zappar config'));
    }

    postProjectLoad() {
        this._loadConfig().catch((e) => this._setError(e, 'Failed to load Zappar config'));
    }
}
