/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */
import { simpleGit } from 'simple-git';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../../ui/state/extensions.js';
import * as os from 'node:os';
import * as https from 'node:https';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { EXTENSIONS_CONFIG_FILENAME, loadExtension } from '../extension.js';
import * as tar from 'tar';
import extract from 'extract-zip';
import { fetchJson, getGitHubToken } from './github_fetch.js';
/**
 * Clones a Git repository to a specified local path.
 * @param installMetadata The metadata for the extension to install.
 * @param destination The destination path to clone the repository to.
 */
export async function cloneFromGit(installMetadata, destination) {
    try {
        const git = simpleGit(destination);
        let sourceUrl = installMetadata.source;
        const token = getGitHubToken();
        if (token) {
            try {
                const parsedUrl = new URL(sourceUrl);
                if (parsedUrl.protocol === 'https:' &&
                    parsedUrl.hostname === 'github.com') {
                    if (!parsedUrl.username) {
                        parsedUrl.username = token;
                    }
                    sourceUrl = parsedUrl.toString();
                }
            }
            catch {
                // If source is not a valid URL, we don't inject the token.
                // We let git handle the source as is.
            }
        }
        await git.clone(sourceUrl, './', ['--depth', '1']);
        const remotes = await git.getRemotes(true);
        if (remotes.length === 0) {
            throw new Error(`Unable to find any remotes for repo ${installMetadata.source}`);
        }
        const refToFetch = installMetadata.ref || 'HEAD';
        await git.fetch(remotes[0].name, refToFetch);
        // After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
        // This results in a detached HEAD state, which is fine for this purpose.
        await git.checkout('FETCH_HEAD');
    }
    catch (error) {
        throw new Error(`Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`, {
            cause: error,
        });
    }
}
export function parseGitHubRepoForReleases(source) {
    // Default to a github repo path, so `source` can be just an org/repo
    const parsedUrl = URL.parse(source, 'https://github.com');
    // The pathname should be "/owner/repo".
    const parts = parsedUrl?.pathname
        .substring(1)
        .split('/')
        // Remove the empty segments, fixes trailing slashes
        .filter((part) => part !== '');
    if (parts?.length !== 2 || parsedUrl?.host !== 'github.com') {
        throw new Error(`Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`);
    }
    const owner = parts[0];
    const repo = parts[1].replace('.git', '');
    if (owner.startsWith('git@github.com')) {
        throw new Error(`GitHub release-based extensions are not supported for SSH. You must use an HTTPS URI with a personal access token to download releases from private repositories. You can set your personal access token in the GITHUB_TOKEN environment variable and install the extension via SSH.`);
    }
    return { owner, repo };
}
export async function fetchReleaseFromGithub(owner, repo, ref, allowPreRelease) {
    if (ref) {
        return await fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`);
    }
    if (!allowPreRelease) {
        // Grab the release that is tagged as the "latest", github does not allow
        // this to be a pre-release so we can blindly grab it.
        return await fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases/latest`);
    }
    // If pre-releases are allowed, we just grab the most recent release.
    const releases = await fetchJson(`https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`);
    if (releases.length === 0) {
        throw new Error('No releases found');
    }
    return releases[0];
}
export async function checkForExtensionUpdate(extension, cwd = process.cwd()) {
    const installMetadata = extension.installMetadata;
    if (installMetadata?.type === 'local') {
        const newExtension = loadExtension({
            extensionDir: installMetadata.source,
            workspaceDir: cwd,
        });
        if (!newExtension) {
            console.error(`Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`);
            return ExtensionUpdateState.ERROR;
        }
        if (newExtension.version !== extension.version) {
            return ExtensionUpdateState.UPDATE_AVAILABLE;
        }
        return ExtensionUpdateState.UP_TO_DATE;
    }
    if (!installMetadata ||
        (installMetadata.type !== 'git' &&
            installMetadata.type !== 'github-release')) {
        return ExtensionUpdateState.NOT_UPDATABLE;
    }
    try {
        if (installMetadata.type === 'git') {
            const git = simpleGit(extension.path);
            const remotes = await git.getRemotes(true);
            if (remotes.length === 0) {
                console.error('No git remotes found.');
                return ExtensionUpdateState.ERROR;
            }
            const remoteUrl = remotes[0].refs.fetch;
            if (!remoteUrl) {
                console.error(`No fetch URL found for git remote ${remotes[0].name}.`);
                return ExtensionUpdateState.ERROR;
            }
            // Determine the ref to check on the remote.
            const refToCheck = installMetadata.ref || 'HEAD';
            const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
            if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
                console.error(`Git ref ${refToCheck} not found.`);
                return ExtensionUpdateState.ERROR;
            }
            const remoteHash = lsRemoteOutput.split('\t')[0];
            const localHash = await git.revparse(['HEAD']);
            if (!remoteHash) {
                console.error(`Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`);
                return ExtensionUpdateState.ERROR;
            }
            if (remoteHash === localHash) {
                return ExtensionUpdateState.UP_TO_DATE;
            }
            return ExtensionUpdateState.UPDATE_AVAILABLE;
        }
        else {
            const { source, releaseTag } = installMetadata;
            if (!source) {
                console.error(`No "source" provided for extension.`);
                return ExtensionUpdateState.ERROR;
            }
            const { owner, repo } = parseGitHubRepoForReleases(source);
            const releaseData = await fetchReleaseFromGithub(owner, repo, installMetadata.ref, installMetadata.allowPreRelease);
            if (releaseData.tag_name !== releaseTag) {
                return ExtensionUpdateState.UPDATE_AVAILABLE;
            }
            return ExtensionUpdateState.UP_TO_DATE;
        }
    }
    catch (error) {
        console.error(`Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`);
        return ExtensionUpdateState.ERROR;
    }
}
export async function downloadFromGitHubRelease(installMetadata, destination) {
    const { source, ref, allowPreRelease: preRelease } = installMetadata;
    const { owner, repo } = parseGitHubRepoForReleases(source);
    try {
        const releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
        if (!releaseData) {
            throw new Error(`No release data found for ${owner}/${repo} at tag ${ref}`);
        }
        const asset = findReleaseAsset(releaseData.assets);
        let archiveUrl;
        let isTar = false;
        let isZip = false;
        if (asset) {
            archiveUrl = asset.browser_download_url;
        }
        else {
            if (releaseData.tarball_url) {
                archiveUrl = releaseData.tarball_url;
                isTar = true;
            }
            else if (releaseData.zipball_url) {
                archiveUrl = releaseData.zipball_url;
                isZip = true;
            }
        }
        if (!archiveUrl) {
            throw new Error(`No assets found for release with tag ${releaseData.tag_name}`);
        }
        let downloadedAssetPath = path.join(destination, path.basename(new URL(archiveUrl).pathname));
        if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
            downloadedAssetPath += '.tar.gz';
        }
        else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
            downloadedAssetPath += '.zip';
        }
        await downloadFile(archiveUrl, downloadedAssetPath);
        await extractFile(downloadedAssetPath, destination);
        // For regular github releases, the repository is put inside of a top level
        // directory. In this case we should see exactly two file in the destination
        // dir, the archive and the directory. If we see that, validate that the
        // dir has a gemini extension configuration file and then move all files
        // from the directory up one level into the destination directory.
        const entries = await fs.promises.readdir(destination, {
            withFileTypes: true,
        });
        if (entries.length === 2) {
            const lonelyDir = entries.find((entry) => entry.isDirectory());
            if (lonelyDir &&
                fs.existsSync(path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME))) {
                const dirPathToExtract = path.join(destination, lonelyDir.name);
                const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
                for (const file of extractedDirFiles) {
                    await fs.promises.rename(path.join(dirPathToExtract, file), path.join(destination, file));
                }
                await fs.promises.rmdir(dirPathToExtract);
            }
        }
        await fs.promises.unlink(downloadedAssetPath);
        return {
            tagName: releaseData.tag_name,
            type: 'github-release',
        };
    }
    catch (error) {
        throw new Error(`Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`);
    }
}
export function findReleaseAsset(assets) {
    const platform = os.platform();
    const arch = os.arch();
    const platformArchPrefix = `${platform}.${arch}.`;
    const platformPrefix = `${platform}.`;
    // Check for platform + architecture specific asset
    const platformArchAsset = assets.find((asset) => asset.name.toLowerCase().startsWith(platformArchPrefix));
    if (platformArchAsset) {
        return platformArchAsset;
    }
    // Check for platform specific asset
    const platformAsset = assets.find((asset) => asset.name.toLowerCase().startsWith(platformPrefix));
    if (platformAsset) {
        return platformAsset;
    }
    // Check for generic asset if only one is available
    const genericAsset = assets.find((asset) => !asset.name.toLowerCase().includes('darwin') &&
        !asset.name.toLowerCase().includes('linux') &&
        !asset.name.toLowerCase().includes('win32'));
    if (assets.length === 1) {
        return genericAsset;
    }
    return undefined;
}
async function downloadFile(url, dest) {
    const headers = {
        'User-agent': 'gemini-cli',
    };
    const token = getGitHubToken();
    if (token) {
        headers.Authorization = `token ${token}`;
    }
    return new Promise((resolve, reject) => {
        https
            .get(url, { headers }, (res) => {
            if (res.statusCode === 302 || res.statusCode === 301) {
                downloadFile(res.headers.location, dest).then(resolve).catch(reject);
                return;
            }
            if (res.statusCode !== 200) {
                return reject(new Error(`Request failed with status code ${res.statusCode}`));
            }
            const file = fs.createWriteStream(dest);
            res.pipe(file);
            file.on('finish', () => file.close(resolve));
        })
            .on('error', reject);
    });
}
export async function extractFile(file, dest) {
    if (file.endsWith('.tar.gz')) {
        await tar.x({
            file,
            cwd: dest,
        });
    }
    else if (file.endsWith('.zip')) {
        await extract(file, { dir: dest });
    }
    else {
        throw new Error(`Unsupported file extension for extraction: ${file}`);
    }
}
//# sourceMappingURL=github.js.map