// @ts-check
import { exec, execSync } from 'child_process';
import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
import path from 'path';
import { needleLog } from './logging.js';

/** @typedef {{dependencies?: Record<string, string>, devDependencies?: Record<string, string>}} PackageJson */

/** @param {...string} msg */
function log(...msg) {
    needleLog("needle-dependency-watcher", msg.join(" "));
}

/**
 * @param {"build" | "serve"} command
 * @param {unknown} _config
 * @param {import('../types').userSettings} userSettings
 * @returns {import('vite').Plugin | null}
 */
export function needleDependencyWatcher(command, _config, userSettings) {
    if (command === "build") return null;

    if (userSettings?.noDependencyWatcher === true) return null;

    const dir = process.cwd();
    const packageJsonPath = path.join(dir, "package.json");
    const viteCacheDir = path.join(dir, "node_modules", ".vite");

    return /** @type {import('vite').Plugin} */ ({
        name: 'needle-dependency-watcher',
        /** @param {import('vite').ViteDevServer} server */
        configureServer(server) {
            manageClients(server);
            const startWatching = () => watchPackageJson(server, dir, packageJsonPath, viteCacheDir);
            if (server.httpServer?.listening) {
                startWatching();
            }
            else {
                server.httpServer?.once("listening", startWatching);
            }
        }
    });
}

/** @type {Set<{send: (data: string) => void, on: (event: string, cb: () => void) => void}>} */
const currentClients = new Set();

/** @param {import('vite').ViteDevServer} server */
function manageClients(server) {
    server.ws.on("connection", (socket) => {
        currentClients.add(socket);
        socket.on("close", () => {
            currentClients.delete(socket);
        });
    });
}

async function triggerReloadOnClients() {
    log(`Triggering reload on ${currentClients.size} clients...`)
    for (const client of currentClients) {
        client.send(JSON.stringify({ type: "full-reload" }));
    }
    return new Promise((resolve) => {
        setTimeout(resolve, 100);
    });
}


/** @type {import('fs').Stats | undefined} */
let packageJsonStat;
/** @type {Date | undefined} */
let lastEditTime;
/** @type {number | undefined} */
let packageJsonSize;
/** @type {PackageJson | undefined} */
let packageJson;
let requireInstall = false;

/**
 * @param {import('vite').ViteDevServer} server
 * @param {string} projectDir
 * @param {string} packageJsonPath
 * @param {string} cachePath
 */
function watchPackageJson(server, projectDir, packageJsonPath, cachePath) {

    if (!existsSync(packageJsonPath)) {
        return;
    }

    log("Watching project", packageJsonPath)

    lastRestartTime = 0;
    packageJsonStat = statSync(packageJsonPath);
    lastEditTime = packageJsonStat.mtime;
    packageJsonSize = packageJsonStat.size;
    /** @type {PackageJson} */
    packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));

    setTimeout(() => {
        requireInstall = testIfInstallIsRequired(projectDir, packageJson);
    }, 1000);

    setInterval(() => {
        if (!packageJson || lastEditTime === undefined) return;
        packageJsonStat = statSync(packageJsonPath);
        let modified = false;
        if (packageJsonStat.mtime > lastEditTime) {
            modified = true;
        }
        if (packageJsonStat.size !== packageJsonSize) {
            modified = true;
        }
        if (modified || requireInstall) {

            let requireReload = false;
            if (!requireInstall) {
                requireInstall = testIfInstallIsRequired(projectDir, packageJson);
            }

            // test if dependencies changed
            let newPackageJson = /** @type {PackageJson} */ (JSON.parse(readFileSync(packageJsonPath, "utf8")));
            if (newPackageJson.dependencies) {
                for (const key in newPackageJson.dependencies) {
                    if (packageJson.dependencies?.[key] !== newPackageJson.dependencies[key] && newPackageJson.dependencies[key] !== undefined) {
                        log("Detected new dependency: " + key)
                        requireReload = true;
                        requireInstall = true;
                    }
                }
            }
            if (packageJson.devDependencies && newPackageJson.devDependencies) {
                for (const key in packageJson.devDependencies) {
                    if (packageJson.devDependencies[key] !== newPackageJson.devDependencies[key] && newPackageJson.devDependencies[key] !== undefined) {
                        log("Detected new devDependency: " + key)
                        requireReload = true;
                        requireInstall = true;
                    }
                }
            }

            if (modified) {
                log("package.json has changed. Require install: " + (requireInstall ? "yes" : "no"))
            }

            packageJsonSize = packageJsonStat.size;
            lastEditTime = packageJsonStat.mtime;

            if (requireReload || requireInstall) {
                restart(server, projectDir, cachePath);
            }
        }
    }, 2000);
}

/** @param {string} projectDir @param {PackageJson | undefined} packageJson @returns {boolean} */
function testIfInstallIsRequired(projectDir, packageJson) {
    if (!packageJson) return false;
    if (packageJson.dependencies) {
        for (const key in packageJson.dependencies) {
            // make sure the dependency is installed
            const depPath = path.join(projectDir, "node_modules", key);
            if (!existsSync(depPath)) {
                const val = packageJson.dependencies[key];
                if (val.startsWith("file:")) {
                    // check if the local path exists:
                    const dirPath = val.substr(5);
                    // check first the value in case it's a absolute path
                    if (!existsSync(dirPath)) {
                        // then check concatenated with the project dir in case it's a relative path
                        const relPath = path.join(projectDir, val.substr(5));
                        if (!existsSync(relPath)) {
                            // if neither directories exist then the local path is invalid and we ignore it
                            continue;
                        }
                    }
                }
                log("Dependency not installed: " + key)
                return true;
            }
            else {
                let expectedVersion = "";
                const value = packageJson.dependencies[key];
                if (value.startsWith("file:")) {
                    const packageJsonPath = path.join(projectDir, value.substr(5), "package.json");
                    if (existsSync(packageJsonPath)) {
                        expectedVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
                    }
                }
                else {
                    expectedVersion = value;
                }
                if (expectedVersion?.length > 0) {
                    const isRange = expectedVersion.includes("x") || expectedVersion.includes("^") || expectedVersion.includes(">") || expectedVersion.includes("<") || expectedVersion.includes("~");
                    const isTagName = expectedVersion === "stable" ||
                        expectedVersion === "latest" ||
                        expectedVersion === "next" ||
                        expectedVersion === "beta" ||
                        expectedVersion === "alpha" ||
                        expectedVersion === "canary" ||
                        expectedVersion === "experimental";
                    // We can not automatically install dependencies with "4" or "4.6" as a version
                    // const isFullSemver = expectedVersion.match(/^\d+\.\d+\.\d+(-\w+)?(\.\d+)?$/);
                    if (!isRange && !isTagName) {
                        const packageJsonPath = path.join(depPath, "package.json");
                        /** @type {String} */
                        const installedVersion = JSON.parse(readFileSync(packageJsonPath, "utf8")).version;
                        let versionToCompare = expectedVersion.split("@").pop();
                        while (versionToCompare?.startsWith("^")) versionToCompare = versionToCompare.substring(1);
                        // fix check for cases where the version contains a alias e.g. npm:three@0.160.3
                        const actualVersion = installedVersion.split("@").pop();
                        if (actualVersion && versionToCompare && versionToCompare.trimEnd().startsWith(actualVersion.trim()) == false) {
                            log(`Dependency ${key} is installed but version is not the right one. Expected \"${expectedVersion}/" but got \"${installedVersion}\"`)
                            return true;
                        }
                    }
                }
            }
        }
    }
    return false;
}

let isRunningRestart = false;
let restartId = 0;
let lastRestartTime = 0;

/** @param {import('vite').ViteDevServer} server @param {string} projectDir @param {string} cachePath */
async function restart(server, projectDir, cachePath) {

    if (isRunningRestart) return;
    isRunningRestart = true;

    try {
        const id = ++restartId;

        if (requireInstall) {
            requireInstall = false;
            log("Installing dependencies...")
            execSync("npm install", { cwd: projectDir, stdio: "inherit" });
            requireInstall = false;
        }

        if (id !== restartId) return;
        if (Date.now() - lastRestartTime < 3000) return;
        log("Restarting server...")
        lastRestartTime = Date.now();
        requireInstall = false;
        if (existsSync(cachePath))
            rmSync(cachePath, { recursive: true, force: true });
        await triggerReloadOnClients();

        // touch vite config to trigger reload
        // const viteConfigPath = path.join(projectDir, "vite.config.js");
        // if (existsSync(viteConfigPath)) {
        //     const content = readFileSync(viteConfigPath, "utf8");
        //     writeFileSync(viteConfigPath, content, "utf8");
        //     isRunningRestart = false;
        //     return;
        // }

        // check if server is running
        if (server.httpServer?.listening) {
            server.restart();
        }
        isRunningRestart = false;
        needleLog("needle-dependency-watcher", "-----------------------------------------------");
    }
    catch (err) {
        log("Error restarting server", String(err));
        isRunningRestart = false;
    }

}