import { HTTP, HTTPError } from '@heroku/http-call';
import { CLIError, warn } from '@oclif/core/errors';
import debug from 'debug';
import { Netrc } from 'netrc-parser';
import * as url from 'node:url';
import { Login } from './login.js';
import { Mutex } from './mutex.js';
import { ParticleboardClient } from './particleboard-client.js';
import { prompter } from './prompter.js';
import { RequestId, requestIdHeader } from './request-id.js';
import { vars } from './vars.js';
import { yubikey } from './yubikey.js';
// Defer netrc instantiation to avoid eager .netrc file operations at module load time
let _netrc;
function getNetrc() {
    if (!_netrc) {
        _netrc = new Netrc();
    }
    return _netrc;
}
export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com']);
export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1']);
export class HerokuAPIError extends CLIError {
    body;
    http;
    constructor(httpError) {
        if (!httpError)
            throw new Error('invalid error');
        const options = httpError.body;
        if (!options || !options.message)
            throw httpError;
        const info = [];
        if (options.id)
            info.push(`Error ID: ${options.id}`);
        if (options.app && options.app.name)
            info.push(`App: ${options.app.name}`);
        if (options.url)
            info.push(`See ${options.url} for more information.`);
        if (info.length > 0)
            super([options.message, '', ...info].join('\n'));
        else
            super(options.message);
        this.http = httpError;
        this.body = options;
    }
}
export class APIClient {
    config;
    options;
    authPromise;
    http;
    preauthPromises;
    _auth;
    _login;
    _particleboard;
    _twoFactorMutex;
    constructor(config, options = {}) {
        this.config = config;
        this.options = options;
        this.config = config;
        this._login = new Login(this.config, this);
        if (options.required === undefined)
            options.required = true;
        options.preauth = options.preauth !== false;
        if (options.debug)
            debug.enable('http');
        if (options.debug && options.debugHeaders)
            debug.enable('http,http:headers');
        this.options = options;
        const apiUrl = new url.URL(vars.apiUrl);
        const envHeaders = JSON.parse(process.env.HEROKU_HEADERS || '{}');
        this.preauthPromises = {};
        const self = this;
        const opts = {
            headers: {
                accept: 'application/vnd.heroku+json; version=3',
                'user-agent': `heroku-cli/${self.config.version} ${self.config.platform}`,
                ...envHeaders,
            },
            host: apiUrl.hostname,
            port: apiUrl.port,
            protocol: apiUrl.protocol,
        };
        const delinquencyConfig = { fetch_delinquency: false, warning_shown: false };
        this.http = class APIHTTPClient extends HTTP.create(opts) {
            static configDelinquency(url, opts) {
                if (opts.method?.toUpperCase() !== 'GET' || (opts.hostname && opts.hostname !== apiUrl.hostname)) {
                    delinquencyConfig.fetch_delinquency = false;
                    return;
                }
                if (/^\/account$/i.test(url)) {
                    delinquencyConfig.fetch_url = '/account';
                    delinquencyConfig.fetch_delinquency = true;
                    delinquencyConfig.resource_type = 'account';
                    return;
                }
                const match = url.match(/^\/teams\/([^#/?]+)/i);
                if (match) {
                    delinquencyConfig.fetch_url = `/teams/${match[1]}`;
                    delinquencyConfig.fetch_delinquency = true;
                    delinquencyConfig.resource_type = 'team';
                    return;
                }
                delinquencyConfig.fetch_delinquency = false;
            }
            static notifyDelinquency(delinquencyInfo) {
                const suspension = delinquencyInfo.scheduled_suspension_time ? Date.parse(delinquencyInfo.scheduled_suspension_time).valueOf() : undefined;
                const deletion = delinquencyInfo.scheduled_deletion_time ? Date.parse(delinquencyInfo.scheduled_deletion_time).valueOf() : undefined;
                if (!suspension && !deletion)
                    return;
                const resource = delinquencyConfig.resource_type;
                if (suspension) {
                    const now = Date.now();
                    if (suspension > now) {
                        warn(`This ${resource} is delinquent with payment and we'll suspend it on ${new Date(suspension)}.`);
                        delinquencyConfig.warning_shown = true;
                        return;
                    }
                    if (deletion)
                        warn(`This ${resource} is delinquent with payment and we suspended it on ${new Date(suspension)}. If the ${resource} is still delinquent, we'll delete it on ${new Date(deletion)}.`);
                }
                else if (deletion)
                    warn(`This ${resource} is delinquent with payment and we'll delete it on ${new Date(deletion)}.`);
                delinquencyConfig.warning_shown = true;
            }
            // eslint-disable-next-line complexity
            static async request(url, opts = {}, retries = 3) {
                opts.headers = opts.headers || {};
                const currentRequestId = RequestId.create() && RequestId.headerValue;
                // Accumulation of requestIds in the header
                // causes a header overflow error. Headers have been
                // observed to be larger than 8k (Node default max)
                // in long running poll operations such as pg:wait
                // We limit the Request-Id header to 7k to allow some
                // room fo other headers.
                if (Buffer.from(currentRequestId).byteLength > 1024 * 7) {
                    RequestId.empty();
                    opts.headers[requestIdHeader] = RequestId.create();
                }
                else {
                    opts.headers[requestIdHeader] = currentRequestId;
                }
                if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) {
                    // Handle both relative and absolute URLs for validation
                    let targetUrl;
                    try {
                        // Try absolute URL first
                        targetUrl = new URL(url);
                    }
                    catch {
                        // If that fails, assume it's relative and prepend the API base URL
                        targetUrl = new URL(url, vars.apiUrl);
                    }
                    const isHerokuApi = ALLOWED_HEROKU_DOMAINS.some(domain => targetUrl.hostname.endsWith(`.${domain}`) || targetUrl.hostname === domain);
                    const isLocalhost = LOCALHOST_DOMAINS.includes(targetUrl.hostname);
                    if (isHerokuApi || isLocalhost) {
                        opts.headers.authorization = `Bearer ${self.auth}`;
                    }
                }
                this.configDelinquency(url, opts);
                retries--;
                try {
                    let response;
                    let particleboardResponse;
                    const particleboardClient = self.particleboard;
                    if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) {
                        self._particleboard.auth = self.auth;
                        const settledResponses = await Promise.allSettled([
                            super.request(url, opts),
                            particleboardClient.get(delinquencyConfig.fetch_url),
                        ]);
                        // Platform API request
                        if (settledResponses[0].status === 'fulfilled')
                            response = settledResponses[0].value;
                        else
                            throw settledResponses[0].reason;
                        // Particleboard request (ignore errors)
                        if (settledResponses[1].status === 'fulfilled') {
                            particleboardResponse = settledResponses[1].value;
                        }
                    }
                    else {
                        response = await super.request(url, opts);
                    }
                    const delinquencyInfo = particleboardResponse?.body || {};
                    this.notifyDelinquency(delinquencyInfo);
                    this.trackRequestIds(response);
                    this.showWarnings(response);
                    return response;
                }
                catch (error) {
                    if (!(error instanceof HTTPError))
                        throw error;
                    if (retries > 0) {
                        if (opts.retryAuth !== false && error.http.statusCode === 401 && error.body.id === 'unauthorized') {
                            if (process.env.HEROKU_API_KEY) {
                                throw new Error('The token provided to HEROKU_API_KEY is invalid. Please double-check that you have the correct token, or run `heroku login` without HEROKU_API_KEY set.');
                            }
                            if (!self.authPromise)
                                self.authPromise = self.login();
                            await self.authPromise;
                            opts.headers.authorization = `Bearer ${self.auth}`;
                            return this.request(url, opts, retries);
                        }
                        if (error.http.statusCode === 403 && error.body.id === 'two_factor') {
                            return this.twoFactorRetry(error, url, opts, retries);
                        }
                    }
                    throw new HerokuAPIError(error);
                }
            }
            static showWarnings(response) {
                const warnings = response.headers['x-heroku-warning'] || response.headers['warning-message'];
                if (Array.isArray(warnings))
                    for (const warning of warnings)
                        warn(warning.replace(/^\s*warning:?\s*/i, ''));
                else if (typeof warnings === 'string')
                    warn(warnings.replace(/^\s*warning:?\s*/i, ''));
            }
            static trackRequestIds(response) {
                const responseRequestIdHeader = response.headers[requestIdHeader] || response.headers[requestIdHeader.toLocaleLowerCase()];
                if (responseRequestIdHeader) {
                    const requestIds = Array.isArray(responseRequestIdHeader) ? responseRequestIdHeader : responseRequestIdHeader.split(',');
                    RequestId.track(...requestIds);
                }
            }
            static async twoFactorRetry(err, url, opts = {}, retries = 3) {
                const app = err.body.app ? err.body.app.name : null;
                if (!app || !options.preauth) {
                    opts.headers = opts.headers || {};
                    opts.headers['Heroku-Two-Factor-Code'] = await self.twoFactorPrompt();
                    return this.request(url, opts, retries);
                }
                // if multiple requests are run in parallel for the same app, we should
                // only preauth for the first so save the fact we already preauthed
                if (!self.preauthPromises[app]) {
                    self.preauthPromises[app] = self.twoFactorPrompt().then((factor) => self.preauth(app, factor));
                }
                await self.preauthPromises[app];
                return this.request(url, opts, retries);
            }
        };
    }
    get auth() {
        if (!this._auth) {
            if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY)
                warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY');
            this._auth = process.env.HEROKU_API_KEY;
            if (!this._auth) {
                const netrc = getNetrc();
                netrc.loadSync();
                this._auth = netrc.machines[vars.apiHost] && netrc.machines[vars.apiHost].password;
            }
        }
        return this._auth;
    }
    set auth(token) {
        delete this.authPromise;
        this._auth = token;
    }
    get defaults() {
        return this.http.defaults;
    }
    get particleboard() {
        if (this._particleboard)
            return this._particleboard;
        this._particleboard = new ParticleboardClient(this.config);
        return this._particleboard;
    }
    get twoFactorMutex() {
        if (!this._twoFactorMutex) {
            this._twoFactorMutex = new Mutex();
        }
        return this._twoFactorMutex;
    }
    delete(url, options = {}) {
        return this.http.delete(url, options);
    }
    get(url, options = {}) {
        return this.http.get(url, options);
    }
    login(opts = {}) {
        return this._login.login(opts);
    }
    async logout() {
        try {
            await this._login.logout();
        }
        catch (error) {
            if (error instanceof CLIError)
                warn(error);
        }
        const netrc = getNetrc();
        delete netrc.machines['api.heroku.com'];
        delete netrc.machines['git.heroku.com'];
        await netrc.save();
    }
    patch(url, options = {}) {
        return this.http.patch(url, options);
    }
    post(url, options = {}) {
        return this.http.post(url, options);
    }
    preauth(app, factor) {
        return this.put(`/apps/${app}/pre-authorizations`, {
            headers: { 'Heroku-Two-Factor-Code': factor },
        });
    }
    put(url, options = {}) {
        return this.http.put(url, options);
    }
    request(url, options = {}) {
        return this.http.request(url, options);
    }
    stream(url, options = {}) {
        return this.http.stream(url, options);
    }
    twoFactorPrompt() {
        yubikey.enable();
        return this.twoFactorMutex.synchronize(async () => {
            try {
                const { factor } = await prompter.prompt([{
                        mask: '*',
                        message: 'Two-factor code',
                        name: 'factor',
                        type: 'password',
                    }]);
                yubikey.disable();
                return factor;
            }
            catch (error) {
                yubikey.disable();
                throw error;
            }
        });
    }
}
