const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');

const forbiddenRuntimePathPrefixes = ['/opt/homebrew/', '/usr/local/'];
const systemRuntimePathPrefixes = ['/System/Library/', '/usr/lib/'];

function run(command, args, options = {}) {
    const result = spawnSync(command, args, {
        stdio: options.stdio ?? 'pipe',
        encoding: 'utf8',
        ...options,
    });

    if (result.status !== 0) {
        const stderr = result.stderr ? `\n${result.stderr}` : '';
        throw new Error(
            `${command} ${args.join(' ')} failed with status ${result.status ?? 1}.${stderr}`
        );
    }

    return result.stdout ?? '';
}

function commandExists(command) {
    const result = spawnSync('sh', ['-lc', `command -v ${command}`], {
        stdio: 'ignore',
    });
    return result.status === 0;
}

function ensureDir(directoryPath) {
    fs.mkdirSync(directoryPath, { recursive: true });
}

function removeDir(directoryPath) {
    fs.rmSync(directoryPath, { recursive: true, force: true });
}

function copyFile(sourcePath, destinationPath) {
    ensureDir(path.dirname(destinationPath));
    fs.copyFileSync(sourcePath, destinationPath);
    fs.chmodSync(destinationPath, 0o755);
}

function listDylibs(directoryPath) {
    if (!fs.existsSync(directoryPath)) {
        return [];
    }

    return fs
        .readdirSync(directoryPath, { withFileTypes: true })
        .filter((entry) => entry.isFile() && entry.name.endsWith('.dylib'))
        .map((entry) => path.join(directoryPath, entry.name))
        .sort();
}

function listRuntimeFiles(directoryPath) {
    if (!fs.existsSync(directoryPath)) {
        return [];
    }

    return fs
        .readdirSync(directoryPath, { withFileTypes: true })
        .filter((entry) => entry.isFile())
        .map((entry) => path.join(directoryPath, entry.name))
        .sort();
}

function parseOtoolDependencies(binaryPath) {
    const output = run('otool', ['-L', binaryPath]);
    return output
        .split(/\r?\n/)
        .slice(1)
        .map((line) => line.trim())
        .filter(Boolean)
        .map((line) => line.split(/\s+\(/)[0])
        .filter(Boolean);
}

function parseOtoolRpaths(binaryPath) {
    const output = run('otool', ['-l', binaryPath]);
    const lines = output.split(/\r?\n/);
    const rpaths = [];

    for (let index = 0; index < lines.length; index += 1) {
        if (lines[index].trim() !== 'cmd LC_RPATH') {
            continue;
        }

        for (let offset = index + 1; offset < lines.length; offset += 1) {
            const match = lines[offset]
                .trim()
                .match(/^path\s+(.+?)\s+\(offset\s+\d+\)$/);
            if (match) {
                rpaths.push(match[1]);
                break;
            }
        }
    }

    return rpaths;
}

function readInstallNameId(binaryPath) {
    const result = spawnSync('otool', ['-D', binaryPath], {
        stdio: 'pipe',
        encoding: 'utf8',
    });

    if (result.status !== 0) {
        return null;
    }

    return (
        result.stdout
            .split(/\r?\n/)
            .slice(1)
            .map((line) => line.trim())
            .find(Boolean) ?? null
    );
}

function isSystemDependency(dependencyPath) {
    return systemRuntimePathPrefixes.some((prefix) =>
        dependencyPath.startsWith(prefix)
    );
}

function isForbiddenDependency(dependencyPath) {
    return forbiddenRuntimePathPrefixes.some((prefix) =>
        dependencyPath.startsWith(prefix)
    );
}

function resolvePathToken(tokenPath, binaryPath) {
    if (tokenPath.startsWith('@loader_path/')) {
        return path.resolve(
            path.dirname(binaryPath),
            tokenPath.replace('@loader_path/', '')
        );
    }

    if (tokenPath.startsWith('@executable_path/')) {
        return null;
    }

    if (tokenPath.startsWith('@rpath/')) {
        return null;
    }

    return tokenPath;
}

function resolveRpathDependency(dependencyPath, binaryPath) {
    const dependencyName = dependencyPath.replace('@rpath/', '');

    for (const rpath of parseOtoolRpaths(binaryPath)) {
        const resolvedRpath = resolvePathToken(rpath, binaryPath);
        if (!resolvedRpath) {
            continue;
        }

        const candidatePath = path.resolve(resolvedRpath, dependencyName);
        if (fs.existsSync(candidatePath)) {
            return candidatePath;
        }
    }

    return null;
}

function resolveDependencyPath(dependencyPath, binaryPath) {
    if (dependencyPath.startsWith('@loader_path/')) {
        return resolvePathToken(dependencyPath, binaryPath);
    }

    if (dependencyPath.startsWith('@rpath/')) {
        return resolveRpathDependency(dependencyPath, binaryPath);
    }

    if (dependencyPath.startsWith('@executable_path/')) {
        return null;
    }

    return dependencyPath;
}

function collectExternalDylibs(entryPaths) {
    const visited = new Set();
    const queue = [...entryPaths];
    const result = new Map();

    while (queue.length > 0) {
        const currentPath = queue.shift();
        if (!currentPath || visited.has(currentPath) || !fs.existsSync(currentPath)) {
            continue;
        }

        visited.add(currentPath);
        for (const dependencyPath of parseOtoolDependencies(currentPath)) {
            if (isSystemDependency(dependencyPath)) {
                continue;
            }

            const resolvedPath = resolveDependencyPath(dependencyPath, currentPath);
            if (!resolvedPath || !fs.existsSync(resolvedPath)) {
                continue;
            }

            if (!result.has(path.basename(resolvedPath))) {
                result.set(path.basename(resolvedPath), resolvedPath);
                queue.push(resolvedPath);
            }
        }
    }

    return [...result.values()];
}

function findLibMpv(runtimeLibDir) {
    const candidates = ['libmpv.2.dylib', 'libmpv.dylib'];
    for (const candidate of candidates) {
        const candidatePath = path.join(runtimeLibDir, candidate);
        if (fs.existsSync(candidatePath)) {
            return candidatePath;
        }
    }

    return null;
}

function patchDylibIds(libDir) {
    for (const dylibPath of listRuntimeFiles(libDir)) {
        if (!readInstallNameId(dylibPath)) {
            continue;
        }

        run('install_name_tool', [
            '-id',
            `@loader_path/${path.basename(dylibPath)}`,
            dylibPath,
        ]);
    }
}

function patchBinaryDependencies(binaryPath, dependencyBaseDir, replacementPrefix) {
    const availableRuntimeFiles = new Set(
        listRuntimeFiles(dependencyBaseDir).map((runtimePath) =>
            path.basename(runtimePath)
        )
    );

    for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
        const dependencyName = path.basename(dependencyPath);
        if (!availableRuntimeFiles.has(dependencyName)) {
            continue;
        }

        const replacementPath = `${replacementPrefix}/${dependencyName}`;
        if (dependencyPath === replacementPath) {
            continue;
        }

        run('install_name_tool', [
            '-change',
            dependencyPath,
            replacementPath,
            binaryPath,
        ]);
    }
}

function adHocSignBinary(binaryPath) {
    if (process.platform !== 'darwin' || !commandExists('codesign')) {
        return;
    }

    run('codesign', ['--force', '--sign', '-', '--timestamp=none', binaryPath]);
}

function adHocSignBinaries(binaryPaths) {
    for (const binaryPath of binaryPaths) {
        adHocSignBinary(binaryPath);
    }
}

function copyRuntimeToNativeBuild({
    runtimeLibDir,
    outputLibDir,
    runtimeOrigin,
    runtimeManifest,
}) {
    const libMpvPath = findLibMpv(runtimeLibDir);
    if (!libMpvPath) {
        throw new Error(`Unable to find libmpv in ${runtimeLibDir}.`);
    }

    removeDir(outputLibDir);
    ensureDir(outputLibDir);

    const runtimeFilesByName = new Map([[path.basename(libMpvPath), libMpvPath]]);
    for (const dylibPath of collectExternalDylibs([libMpvPath])) {
        runtimeFilesByName.set(path.basename(dylibPath), dylibPath);
    }
    if (runtimeOrigin === 'vendored-lgpl') {
        for (const runtimePath of listRuntimeFiles(runtimeLibDir)) {
            runtimeFilesByName.set(path.basename(runtimePath), runtimePath);
        }
    }

    const dylibs = [...runtimeFilesByName.values()];
    for (const dylibPath of dylibs) {
        copyFile(dylibPath, path.join(outputLibDir, path.basename(dylibPath)));
    }

    if (!fs.existsSync(path.join(outputLibDir, 'libmpv.2.dylib'))) {
        const copiedLibMpvPath = path.join(outputLibDir, path.basename(libMpvPath));
        if (path.basename(copiedLibMpvPath) !== 'libmpv.2.dylib') {
            copyFile(copiedLibMpvPath, path.join(outputLibDir, 'libmpv.2.dylib'));
        }
    }

    patchDylibIds(outputLibDir);
    for (const runtimePath of listRuntimeFiles(outputLibDir)) {
        patchBinaryDependencies(runtimePath, outputLibDir, '@loader_path');
    }
    adHocSignBinaries(listRuntimeFiles(outputLibDir));

    const manifest = {
        origin: runtimeOrigin,
        generatedAt: new Date().toISOString(),
        libDir: 'lib',
        runtimeFiles: listRuntimeFiles(outputLibDir).map((runtimePath) =>
            path.basename(runtimePath)
        ),
        dylibs: listDylibs(outputLibDir).map((dylibPath) =>
            path.basename(dylibPath)
        ),
        ...runtimeManifest,
    };

    fs.writeFileSync(
        path.join(path.dirname(outputLibDir), 'embedded-mpv-runtime.json'),
        `${JSON.stringify(manifest, null, 2)}\n`
    );

    return manifest;
}

function patchAddonForBundledRuntime(addonPath, outputLibDir) {
    if (!fs.existsSync(addonPath)) {
        throw new Error(`Missing embedded MPV native addon: ${addonPath}`);
    }

    patchBinaryDependencies(addonPath, outputLibDir, '@loader_path/lib');
    adHocSignBinary(addonPath);
}

function validateNoForbiddenRuntimeLinks(binaryPaths) {
    const errors = [];

    for (const binaryPath of binaryPaths) {
        if (!fs.existsSync(binaryPath)) {
            errors.push(`Missing binary: ${binaryPath}`);
            continue;
        }

        for (const dependencyPath of parseOtoolDependencies(binaryPath)) {
            if (isForbiddenDependency(dependencyPath)) {
                errors.push(
                    `${binaryPath} links to forbidden runtime path: ${dependencyPath}`
                );
            }

            if (dependencyPath.startsWith('@loader_path/')) {
                const resolvedPath = path.resolve(
                    path.dirname(binaryPath),
                    dependencyPath.replace('@loader_path/', '')
                );
                if (!fs.existsSync(resolvedPath)) {
                    errors.push(
                        `${binaryPath} links to missing bundled dependency: ${dependencyPath}`
                    );
                }
            }

            if (
                !isSystemDependency(dependencyPath) &&
                !dependencyPath.startsWith('@loader_path/')
            ) {
                errors.push(
                    `${binaryPath} links to non-bundled runtime path: ${dependencyPath}`
                );
            }
        }
    }

    return errors;
}

function validatePackagedEmbeddedMpv(resourceDir, options = {}) {
    if (process.platform !== 'darwin') {
        return [];
    }

    if (!commandExists('otool')) {
        return ['otool is required to validate embedded MPV macOS packaging.'];
    }

    const unpackedNativeDir = path.join(
        resourceDir,
        'app.asar.unpacked',
        'electron-backend',
        'native'
    );
    const addonPath = path.join(unpackedNativeDir, 'embedded_mpv.node');
    const libDir = path.join(unpackedNativeDir, 'lib');
    const manifestPath = path.join(
        unpackedNativeDir,
        'embedded-mpv-runtime.json'
    );
    const errors = [];

    if (!fs.existsSync(addonPath)) {
        if (options.required) {
            errors.push(`Missing embedded MPV native addon: ${addonPath}`);
        }
        return errors;
    }

    if (!fs.existsSync(manifestPath)) {
        errors.push(`Missing embedded MPV runtime manifest: ${manifestPath}`);
    } else {
        const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
        if (manifest.origin !== 'vendored-lgpl') {
            errors.push(
                `Embedded MPV packaged runtime must be vendored-lgpl, received: ${manifest.origin}`
            );
        }
    }

    if (!fs.existsSync(path.join(libDir, 'libmpv.2.dylib'))) {
        errors.push(`Missing bundled libmpv.2.dylib in ${libDir}`);
    }

    const binaries = [addonPath, ...listRuntimeFiles(libDir)];
    errors.push(...validateNoForbiddenRuntimeLinks(binaries));

    return errors;
}

module.exports = {
    collectExternalDylibs,
    commandExists,
    copyRuntimeToNativeBuild,
    findLibMpv,
    listRuntimeFiles,
    listDylibs,
    parseOtoolDependencies,
    parseOtoolRpaths,
    patchAddonForBundledRuntime,
    validateNoForbiddenRuntimeLinks,
    validatePackagedEmbeddedMpv,
};
