import { parse as yamlParse } from 'yaml';
import JSZip from 'jszip';
import { CHAIN_FILE_REGEX, DEFAULT_GITHUB_REGISTRY, GITHUB_FETCH_CONCURRENCY_LIMIT, WARP_ROUTE_CONFIG_FILE_REGEX, WARP_ROUTE_DEPLOY_FILE_REGEX, } from '../consts.js';
import { concurrentMap, parseGitHubPath, stripLeadingSlash } from '../utils.js';
import { BaseRegistry } from './BaseRegistry.js';
import { RegistryType, } from './IRegistry.js';
import { filterWarpRoutesIds, warpRouteConfigPathToId, warpRouteDeployConfigPathToId, } from './warp-utils.js';
export const GITHUB_API_URL = 'https://api.github.com';
export const GITHUB_API_VERSION = '2022-11-28';
// Custom error to capture HTTP status codes for GitHub fetch failures
export class GithubFetchError extends Error {
    status;
    statusText;
    constructor(response) {
        super(`Failed to fetch from github: ${response.status} ${response.statusText}`);
        this.name = 'GithubFetchError';
        this.status = response.status; // numeric HTTP status
        this.statusText = response.statusText;
    }
}
/**
 * A registry that uses a github repository as its data source.
 * Reads are performed via the github API and github's raw content URLs.
 * Writes are not yet supported (TODO)
 */
export class GithubRegistry extends BaseRegistry {
    type = RegistryType.Github;
    url;
    branch;
    repoOwner;
    repoName;
    proxyUrl;
    authToken;
    /** True when running in a browser environment (overridable via options). */
    isBrowser;
    // In-memory cache of archive file entries (relative path -> content as ArrayBuffer)
    archiveEntries;
    // Promise tracking an in-flight archive download/unpack to dedupe parallel calls
    archiveEntriesPromise;
    unimplementedMethods = new Set([
        'addChain',
        'updateChain',
        'removeChain',
        'addWarpRoute',
        'addWarpRouteConfig',
    ]);
    baseApiHeaders = {
        'X-GitHub-Api-Version': GITHUB_API_VERSION,
    };
    constructor(options = {}) {
        super({ uri: options.uri ?? DEFAULT_GITHUB_REGISTRY, logger: options.logger });
        this.url = new URL(this.uri);
        const { repoOwner, repoName, repoBranch } = parseGitHubPath(this.uri);
        this.repoOwner = repoOwner;
        this.repoName = repoName;
        if (options.branch && repoBranch)
            throw new Error('Branch is set in both options and url.');
        this.branch = options.branch ?? repoBranch ?? 'main';
        this.proxyUrl = options.proxyUrl;
        this.authToken = options.authToken;
        this.isBrowser =
            options.isBrowser !== undefined ? options.isBrowser : typeof window !== 'undefined';
    }
    getUri(itemPath) {
        if (!itemPath)
            return super.getUri();
        return this.getRawContentUrl(itemPath);
    }
    async listRegistryContent() {
        if (this.listContentCache)
            return this.listContentCache;
        // This uses the tree API instead of the simpler directory list API because it
        // allows us to get a full view of all files in one request.
        const apiUrl = await this.getApiUrl();
        const response = await this.fetch(apiUrl);
        const result = await response.json();
        const tree = result.tree;
        const chainPath = this.getChainsPath();
        const chains = {};
        const warpRoutes = {};
        const warpDeployConfig = {};
        for (const node of tree) {
            if (CHAIN_FILE_REGEX.test(node.path)) {
                const [_, chainName, fileName, extension] = node.path.match(CHAIN_FILE_REGEX);
                chains[chainName] ??= {};
                // @ts-ignore allow dynamic key assignment
                chains[chainName][fileName] = this.getRawContentUrl(`${chainPath}/${chainName}/${fileName}.${extension}`);
            }
            if (WARP_ROUTE_CONFIG_FILE_REGEX.test(node.path)) {
                const routeId = warpRouteConfigPathToId(node.path);
                warpRoutes[routeId] = this.getRawContentUrl(node.path);
            }
            if (WARP_ROUTE_DEPLOY_FILE_REGEX.test(node.path)) {
                const routeId = warpRouteDeployConfigPathToId(node.path);
                warpDeployConfig[routeId] = this.getRawContentUrl(node.path);
            }
        }
        return (this.listContentCache = { chains, deployments: { warpRoutes, warpDeployConfig } });
    }
    async getChains() {
        const repoContents = await this.listRegistryContent();
        return Object.keys(repoContents.chains);
    }
    async getMetadata() {
        if (this.metadataCache && this.isMetadataCacheFull)
            return this.metadataCache;
        const combinedDataUrl = this.getRawContentUrl(`${this.getChainsPath()}/metadata.yaml`);
        const chainMetadata = await this.fetchYamlFile(combinedDataUrl);
        this.isMetadataCacheFull = true;
        return (this.metadataCache = chainMetadata);
    }
    async getChainMetadata(chainName) {
        if (this.metadataCache?.[chainName])
            return this.metadataCache[chainName];
        const data = await this.fetchChainFile('metadata', chainName);
        if (!data)
            return null;
        this.metadataCache = { ...this.metadataCache, [chainName]: data };
        return data;
    }
    async getAddresses() {
        if (this.addressCache && this.isAddressCacheFull)
            return this.addressCache;
        const combinedDataUrl = this.getRawContentUrl(`${this.getChainsPath()}/addresses.yaml`);
        const chainAddresses = await this.fetchYamlFile(combinedDataUrl);
        this.isAddressCacheFull = true;
        return (this.addressCache = chainAddresses);
    }
    async getChainAddresses(chainName) {
        if (this.addressCache?.[chainName])
            return this.addressCache[chainName];
        const data = await this.fetchChainFile('addresses', chainName);
        if (!data)
            return null;
        this.addressCache = { ...this.addressCache, [chainName]: data };
        return data;
    }
    async addChain(_chains) {
        throw new Error('TODO: Implement');
    }
    async updateChain(_chains) {
        throw new Error('TODO: Implement');
    }
    async removeChain(_chains) {
        throw new Error('TODO: Implement');
    }
    async getWarpRoute(routeId) {
        const repoContents = await this.listRegistryContent();
        const routeConfigUrl = repoContents.deployments.warpRoutes[routeId];
        if (!routeConfigUrl)
            return null;
        return this.fetchYamlFile(routeConfigUrl);
    }
    async getWarpDeployConfig(routeId) {
        const repoContents = await this.listRegistryContent();
        const routeConfigUrl = repoContents.deployments.warpDeployConfig[routeId];
        if (!routeConfigUrl)
            return null;
        return this.fetchYamlFile(routeConfigUrl);
    }
    async getWarpRoutes(filter) {
        if (this.warpRouteCache && this.isWarpRouteCacheFull) {
            const { idMap: filteredWarpRouteConfigs } = filterWarpRoutesIds(this.warpRouteCache, filter);
            return filteredWarpRouteConfigs;
        }
        const combinedDataUrl = this.getRawContentUrl(`${this.getWarpRoutesPath()}/warpRouteConfigs.yaml`);
        const warpRouteConfigs = await this.fetchYamlFile(combinedDataUrl);
        this.isWarpRouteCacheFull = true;
        this.warpRouteCache = warpRouteConfigs;
        const { idMap: filteredWarpRouteConfigs } = filterWarpRoutesIds(warpRouteConfigs, filter);
        return filteredWarpRouteConfigs;
    }
    async getWarpDeployConfigs(filter) {
        const { warpDeployConfig } = (await this.listRegistryContent()).deployments;
        const { ids: routeIds, values: routeConfigUrls } = filterWarpRoutesIds(warpDeployConfig, filter);
        return this.readConfigs(routeIds, routeConfigUrls);
    }
    async readConfigs(routeIds, routeConfigUrls) {
        const configs = await this.fetchYamlFiles(routeConfigUrls);
        const idsWithConfigs = routeIds.map((id, i) => [id, configs[i]]);
        return Object.fromEntries(idsWithConfigs);
    }
    async addWarpRoute(_config) {
        throw new Error('TODO: Implement');
    }
    async addWarpRouteConfig(_config, _options) {
        throw new Error('TODO: Implement');
    }
    async getApiUrl() {
        const { remaining, reset } = await this.getApiRateLimit();
        let apiHost = GITHUB_API_URL;
        if (remaining === 0) {
            if (!this.proxyUrl)
                throw new Error(`Github API rate remaining: ${remaining}, limit reset at ${reset}.`);
            apiHost = this.proxyUrl;
        }
        return `${apiHost}/repos/${this.repoOwner}/${this.repoName}/git/trees/${this.branch}?recursive=true`;
    }
    async getApiRateLimit() {
        const response = await this.fetch(`${GITHUB_API_URL}/rate_limit`);
        const { resources } = (await response.json());
        return resources.core;
    }
    getRawContentUrl(path) {
        path = stripLeadingSlash(path);
        return `https://raw.githubusercontent.com/${this.repoOwner}/${this.repoName}/${this.branch}/${path}`;
    }
    async fetchChainFile(fileName, chainName) {
        const repoContents = await this.listRegistryContent();
        const fileUrl = repoContents.chains[chainName]?.[fileName];
        if (!fileUrl)
            return null;
        return this.fetchYamlFile(fileUrl);
    }
    fetchYamlFiles(urls) {
        return concurrentMap(GITHUB_FETCH_CONCURRENCY_LIMIT, urls, (url) => this.fetchYamlFile(url));
    }
    /**
     * Returns an array of URLs to download the repository zip archive.
     * If authToken is set, returns the GitHub API zipball endpoint URL only.
     * Otherwise returns public archive URLs for branch, tag, and commit-SHA fallbacks.
     */
    async getArchiveDownloadUrls() {
        if (this.authToken) {
            const apiUrl = await this.getApiUrl();
            const origin = new URL(apiUrl).origin;
            return [`${origin}/repos/${this.repoOwner}/${this.repoName}/zipball/${this.branch}`];
        }
        const base = `https://github.com/${this.repoOwner}/${this.repoName}/archive`;
        return [
            `${base}/refs/heads/${this.branch}.zip`,
            `${base}/refs/tags/${this.branch}.zip`,
            `${base}/${this.branch}.zip`,
        ];
    }
    /**
     * Ensure the repository archive is downloaded and entries are cached in memory.
     */
    ensureArchiveEntries() {
        if (this.archiveEntries)
            return Promise.resolve();
        if (this.archiveEntriesPromise)
            return this.archiveEntriesPromise;
        // Kick off a single inflight download/unpack
        this.archiveEntriesPromise = (async () => {
            try {
                // Try each archive URL in order, but only fallback on 404 Not Found
                const archiveUrls = await this.getArchiveDownloadUrls();
                let responseBuf;
                const errors = [];
                for (const url of archiveUrls) {
                    try {
                        const resp = await this.fetch(url);
                        responseBuf = await resp.arrayBuffer();
                        break;
                    }
                    catch (err) {
                        // Retry only on 404 Not Found errors
                        if (err instanceof GithubFetchError && err.status === 404) {
                            errors.push(`${url}: ${err.status} ${err.statusText}`);
                            continue;
                        }
                        // Propagate other errors immediately
                        throw err;
                    }
                }
                if (!responseBuf) {
                    throw new Error(`Failed to download archive for ref ${this.branch}. ` +
                        `Tried URLs: ${archiveUrls.join(', ')}. ` +
                        `Errors: ${errors.join('; ')}`);
                }
                const zip = await JSZip.loadAsync(responseBuf);
                const entries = new Map();
                for (const [filePath, zipEntry] of Object.entries(zip.files)) {
                    if (zipEntry.dir)
                        continue;
                    const parts = filePath.split('/');
                    // Remove the top-level folder (owner-repo-sha)
                    parts.shift();
                    const relativePath = parts.join('/');
                    // Only cache files under 'chains/' or 'deployments/'
                    if (!relativePath.startsWith('chains/') && !relativePath.startsWith('deployments/'))
                        continue;
                    const decompressedBuf = await zipEntry.async('arraybuffer');
                    entries.set(relativePath, decompressedBuf);
                }
                this.archiveEntries = entries;
            }
            catch (err) {
                // Reset on failure to allow retry
                this.archiveEntriesPromise = undefined;
                throw err;
            }
        })();
        return this.archiveEntriesPromise;
    }
    /**
     * Fetch a YAML file, using in-memory archive if available for raw content paths.
     */
    async fetchYamlFile(url) {
        const rawBase = `https://raw.githubusercontent.com/${this.repoOwner}/${this.repoName}/${this.branch}/`;
        if (!this.isBrowser && url.startsWith(rawBase)) {
            await this.ensureArchiveEntries();
            const relativePath = url.substring(rawBase.length);
            const dataBuf = this.archiveEntries?.get(relativePath);
            if (!dataBuf)
                throw new Error(`File not found in archive: ${relativePath}`);
            const data = new TextDecoder('utf-8').decode(dataBuf);
            return yamlParse(data);
        }
        // Fallback to network fetch for non-raw URLs
        const response = await this.fetch(url);
        const data = await response.text();
        return yamlParse(data);
    }
    async fetch(url) {
        this.logger.debug(`Fetching from github: ${url}`);
        const isGithubApiRequest = url.startsWith(GITHUB_API_URL);
        const headers = isGithubApiRequest && !!this.authToken
            ? { ...this.baseApiHeaders, Authorization: `Bearer ${this.authToken}` }
            : undefined;
        const response = await fetch(url, {
            headers,
        });
        if (!response.ok) {
            // Throw custom error with status code for callers to inspect
            throw new GithubFetchError(response);
        }
        return response;
    }
}
