/**
 * CloudflarePagesProvider
 *
 * Live ICloudDeploymentProvider adapter for Cloudflare Pages.
 *
 * Deploy pipeline (happy path):
 *   1. Verify token + discover account id
 *   2. Ensure project exists (create if missing)
 *   3. Upload build output via wrangler CLI fallback
 *      (the Cloudflare direct-upload multi-part HTTP protocol is handled by
 *      wrangler internally; calling it from our ExecFunction lets us stay
 *      zero-dep and correct.)
 *   4. Poll the newly-created deployment until success/failure
 *   5. Emit Uploading → Deploying → Deployed via onProgress
 *
 * Spec 089 — research §1 + §7.
 */
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
    var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
    if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
    else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
    return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
    if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
    return function (target, key) { decorator(target, key, paramIndex); }
};
import { inject, injectable } from 'tsyringe';
import { CloudProviderNotConnectedError } from '../../../domain/errors/cloud-provider-not-connected.error.js';
import { CloudDeploymentProvider, CloudDeploymentStatus, OperationLogLevel, } from '../../../domain/generated/output.js';
import { CloudflareAccountMissingError, CloudflareApiError, CloudflareTokenInvalidError, } from './cloud-deployment-errors.js';
const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4';
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 10 * 60 * 1000;
let CloudflarePagesProvider = class CloudflarePagesProvider {
    tokens;
    fetchFn;
    execFile;
    clock;
    providerId = CloudDeploymentProvider.CloudflarePages;
    displayName = 'Cloudflare Pages';
    enabled = true;
    constructor(tokens, fetchFn, execFile, clock = {
        now: () => Date.now(),
        sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
    }) {
        this.tokens = tokens;
        this.fetchFn = fetchFn;
        this.execFile = execFile;
        this.clock = clock;
    }
    async isConnected() {
        const token = await this.tokens.get(CloudDeploymentProvider.CloudflarePages);
        if (!token)
            return false;
        try {
            await this.verifyToken(token);
            return true;
        }
        catch {
            return false;
        }
    }
    async validateToken(token) {
        await this.verifyToken(token);
        await this.discoverAccountId(token);
    }
    async deploy(input, onProgress, onLog) {
        const log = onLog ?? (() => undefined);
        log(OperationLogLevel.Info, `Starting Cloudflare Pages deploy for "${input.projectName}"`);
        const token = await this.tokens.get(CloudDeploymentProvider.CloudflarePages);
        if (!token) {
            log(OperationLogLevel.Error, 'No Cloudflare token stored for this provider');
            throw new CloudProviderNotConnectedError(this.providerId);
        }
        log(OperationLogLevel.Debug, 'Decrypted Cloudflare token from local store');
        const accountId = await this.discoverAccountId(token, log);
        log(OperationLogLevel.Info, `Using Cloudflare account ${accountId}`);
        await this.ensureProject(token, accountId, input.projectName, log);
        onProgress(CloudDeploymentStatus.Uploading, 'Uploading build output to Cloudflare Pages');
        log(OperationLogLevel.Info, `Running wrangler pages deploy from ${input.buildOutputDir}`);
        const wranglerResult = await this.runWranglerDeploy(token, accountId, input.projectName, input.buildOutputDir, log);
        // Success path A (modern wrangler 4.x+): stdout contained a live URL.
        // Wrangler has ALREADY confirmed the deploy is live, so we skip polling
        // entirely — the previous version of this code treated the URL banner
        // as a deployment id, URL-encoded it into a GET, and got a 403 HTML
        // page back, which produced a false-negative "Deploy failed" even
        // though Cloudflare had actually succeeded.
        if (wranglerResult.url) {
            log(OperationLogLevel.Info, 'Wrangler reported deploy complete — skipping Cloudflare poll');
            onProgress(CloudDeploymentStatus.Deploying, 'Cloudflare is finalising the deployment');
            // Look up the real deployment id via a single API call so the
            // Application row carries a re-hydratable id (not a synthetic stub).
            // If this lookup fails for any reason we still return success — the
            // authoritative signal is wrangler's exit code + URL banner.
            let newestId = null;
            try {
                const newest = await this.fetchNewestDeployment(token, accountId, input.projectName);
                newestId = newest?.id ?? null;
                if (newestId) {
                    log(OperationLogLevel.Debug, `Resolved wrangler URL to deployment id ${newestId}`);
                }
            }
            catch (err) {
                log(OperationLogLevel.Warn, 'Failed to look up deployment id after wrangler success — using URL-derived fallback', err instanceof Error ? err.message : String(err));
            }
            // Prefer the project alias (`<project>.pages.dev`) over wrangler's
            // per-commit preview URL (`<hash>.<project>.pages.dev`). The alias
            // is the stable production URL users expect to see in the app card;
            // the per-commit URL is a one-off preview that changes on every
            // deploy. We keep the wrangler URL as a fallback in case the alias
            // derivation somehow produces an empty string.
            const aliasUrl = this.deriveAliasUrl(input.projectName) || wranglerResult.url;
            onProgress(CloudDeploymentStatus.Deployed, `Live at ${aliasUrl}`);
            log(OperationLogLevel.Info, `Deployment succeeded — live at ${aliasUrl}`);
            return {
                deploymentId: newestId ?? `wrangler-${Date.now()}`,
                url: aliasUrl,
            };
        }
        // Success path B (legacy wrangler): stdout contained a bare deployment
        // id. Fall back to the old polling flow.
        if (wranglerResult.deploymentId) {
            log(OperationLogLevel.Info, `Wrangler returned legacy deployment id ${wranglerResult.deploymentId}`);
            onProgress(CloudDeploymentStatus.Deploying, 'Cloudflare is finalising the deployment');
            const result = await this.pollUntilFinished(token, accountId, input.projectName, wranglerResult.deploymentId, log);
            onProgress(CloudDeploymentStatus.Deployed, `Live at ${result.url}`);
            log(OperationLogLevel.Info, `Deployment succeeded — live at ${result.url ?? '<unknown>'}`);
            return { deploymentId: result.id, url: result.url ?? '' };
        }
        // Neither signal parsed — wrangler changed its output yet again.
        log(OperationLogLevel.Error, 'Wrangler succeeded (exit 0) but neither a URL nor a deployment id could be parsed from its output');
        throw new CloudflareApiError('Could not parse wrangler output for deployment id or URL', 0, []);
    }
    async getStatus(deploymentId) {
        const token = await this.tokens.get(CloudDeploymentProvider.CloudflarePages);
        if (!token)
            throw new CloudProviderNotConnectedError(this.providerId);
        const accountId = await this.discoverAccountId(token);
        // We need the project name to fetch a single deployment — callers that
        // only know the deployment id should usually rely on the Application row
        // instead. This method exists for re-hydration flows.
        // Fallback: scan projects until we find the deployment.
        const projects = await this.listProjects(token, accountId);
        for (const project of projects) {
            try {
                const dep = await this.getDeployment(token, accountId, project.name, deploymentId);
                return {
                    status: mapStageToStatus(dep.latest_stage?.status),
                    url: dep.url,
                };
            }
            catch {
                // Try the next project.
            }
        }
        return { status: CloudDeploymentStatus.Failed, error: 'deployment not found' };
    }
    // ─────────────── internals ───────────────
    /**
     * Build the stable alias URL for a Cloudflare Pages project.
     *
     * Cloudflare serves every successful production deploy at
     * `<project-name>.pages.dev` in addition to the per-commit preview URL
     * (`<hash>.<project-name>.pages.dev`). The alias is what we surface in
     * the app card — it doesn't change between deploys, so users can
     * bookmark it and share it without worrying about stale commit hashes.
     *
     * Project names are already validated by Cloudflare's own API (lowercase
     * alphanumerics + hyphens, 1–58 chars), and our `cleanDeployName`
     * helper produces strings that satisfy those rules, so no further
     * sanitisation is needed here.
     */
    deriveAliasUrl(projectName) {
        const trimmed = projectName.trim();
        if (!trimmed)
            return '';
        return `https://${trimmed}.pages.dev`;
    }
    async verifyToken(token) {
        const res = await this.request('GET', '/user/tokens/verify', token, undefined);
        if (res.status !== 'active') {
            throw new CloudflareTokenInvalidError(`Token status is ${res.status}, expected 'active'`);
        }
    }
    async discoverAccountId(token, log) {
        const accounts = await this.request('GET', '/accounts', token, undefined);
        const first = accounts[0];
        if (!first) {
            log?.(OperationLogLevel.Error, 'Cloudflare returned 0 accounts for this token');
            throw new CloudflareAccountMissingError();
        }
        log?.(OperationLogLevel.Debug, `Cloudflare /accounts returned ${accounts.length} account(s); using "${first.name}" (${first.id})`);
        return first.id;
    }
    async ensureProject(token, accountId, name, log) {
        const projects = await this.listProjects(token, accountId);
        if (projects.some((p) => p.name === name)) {
            log?.(OperationLogLevel.Info, `Reusing existing Cloudflare Pages project "${name}" (no new project created)`);
            return;
        }
        log?.(OperationLogLevel.Info, `No existing project named "${name}" — creating one on Cloudflare Pages`);
        try {
            await this.request('POST', `/accounts/${accountId}/pages/projects`, token, { name, production_branch: 'main' });
            log?.(OperationLogLevel.Info, `Created Cloudflare Pages project "${name}"`);
        }
        catch (err) {
            if (err instanceof CloudflareApiError && err.status === 409) {
                log?.(OperationLogLevel.Warn, `Cloudflare returned 409 (already exists) for project "${name}" — treating as success`);
                return;
            }
            throw err;
        }
    }
    async listProjects(token, accountId) {
        return this.request('GET', `/accounts/${accountId}/pages/projects`, token, undefined);
    }
    async getDeployment(token, accountId, projectName, deploymentId) {
        return this.request('GET', `/accounts/${accountId}/pages/projects/${projectName}/deployments/${deploymentId}`, token, undefined);
    }
    async pollUntilFinished(token, accountId, projectName, deploymentId, log) {
        const deadline = this.clock.now() + POLL_TIMEOUT_MS;
        let lastStatus;
        while (this.clock.now() < deadline) {
            const dep = await this.getDeployment(token, accountId, projectName, deploymentId);
            const status = dep.latest_stage?.status;
            if (status !== lastStatus) {
                log?.(OperationLogLevel.Info, `Cloudflare deployment stage transitioned to "${status ?? 'unknown'}"`);
                lastStatus = status;
            }
            if (status === 'success')
                return dep;
            if (status === 'failure' || status === 'canceled') {
                log?.(OperationLogLevel.Error, `Cloudflare deployment ${status}`, JSON.stringify(dep.latest_stage));
                throw new CloudflareApiError(`Cloudflare Pages deployment ${status}`, 0, []);
            }
            await this.clock.sleep(POLL_INTERVAL_MS);
        }
        log?.(OperationLogLevel.Error, `Polling timed out after ${POLL_TIMEOUT_MS}ms`);
        throw new CloudflareApiError('Cloudflare Pages deployment polling timed out', 0);
    }
    /**
     * Result of a wrangler subprocess invocation. Modern wrangler (4.x+)
     * prints a URL banner on success but NO explicit "Deployment ID:" line,
     * so we return whichever signals we could parse. The caller decides
     * whether to skip polling based on which fields are populated.
     */
    async runWranglerDeploy(token, accountId, projectName, buildDir, log) {
        const { stdout, stderr } = await this.execFile('npx', ['wrangler', 'pages', 'deploy', buildDir, `--project-name=${projectName}`, '--branch=main'], {
            env: {
                ...process.env,
                CLOUDFLARE_API_TOKEN: token,
                CLOUDFLARE_ACCOUNT_ID: accountId,
            },
        });
        // Capture both streams as a multi-line detail so the UI can show a
        // collapsible "wrangler output" entry. Tail the last 50 lines so we
        // don't blow up the row size for huge outputs.
        const tail = (text) => text.split(/\r?\n/).slice(-50).join('\n');
        const combined = [
            stdout && `--- stdout (last 50) ---\n${tail(stdout)}`,
            stderr && `--- stderr (last 50) ---\n${tail(stderr)}`,
        ]
            .filter(Boolean)
            .join('\n');
        log?.(OperationLogLevel.Debug, 'wrangler pages deploy completed', combined || undefined);
        // Modern wrangler (4.x+) success banner contains the live preview URL:
        //   "Deployment complete! Take a peek over at https://<shortid>.<proj>.pages.dev"
        // The URL is the authoritative success signal — if wrangler printed it
        // AND exited 0, the deploy is live. We return the URL so the caller can
        // skip polling entirely; a subsequent API call will look up the real
        // deployment id for status re-hydration.
        const urlMatch = stdout.match(/https:\/\/[a-z0-9]+\.[^\s)"'<>]+\.pages\.dev/i);
        if (urlMatch) {
            return { deploymentId: null, url: urlMatch[0] };
        }
        // Legacy wrangler (pre-4.x) printed "Deployment ID: <uuid>" instead.
        const idMatch = stdout.match(/Deployment ID:\s*([A-Za-z0-9-]+)/i);
        if (idMatch?.[1])
            return { deploymentId: idMatch[1], url: null };
        // Neither signal parsed — wrangler changed output again, or the command
        // failed silently. Return nulls so the caller decides how to proceed.
        return { deploymentId: null, url: null };
    }
    /**
     * Fetch the single newest deployment for a project. Used right after a
     * wrangler success banner to look up the real deployment id so we can
     * persist it on the Application row for later status re-hydration.
     */
    async fetchNewestDeployment(token, accountId, projectName) {
        const results = await this.request('GET', `/accounts/${accountId}/pages/projects/${projectName}/deployments?per_page=1&page=1`, token, undefined);
        return results[0] ?? null;
    }
    async request(method, path, token, body) {
        const url = `${CLOUDFLARE_API_BASE}${path}`;
        const res = await this.fetchFn(url, {
            method,
            headers: {
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
            },
            body: body === undefined ? undefined : JSON.stringify(body),
        });
        // Read the body as text first so we can produce a useful error when
        // Cloudflare's edge returns an HTML error page (5xx, captive portal,
        // rate-limit page, etc.). Parsing JSON before checking the response shape
        // would otherwise blow up with `SyntaxError: Unexpected token '<'` and
        // hide the real status from the user.
        const rawText = await res.text().catch(() => '');
        const contentType = res.headers.get('content-type') ?? '';
        const looksLikeJson = contentType.includes('application/json') || rawText.startsWith('{');
        if (!looksLikeJson) {
            throw new CloudflareApiError(`Cloudflare API ${method} ${path} returned a non-JSON response (HTTP ${res.status}). ` +
                `First 200 chars: ${snippetForError(rawText)}`, res.status, []);
        }
        let envelope;
        try {
            envelope = JSON.parse(rawText);
        }
        catch {
            throw new CloudflareApiError(`Cloudflare API ${method} ${path} returned malformed JSON (HTTP ${res.status}). ` +
                `First 200 chars: ${snippetForError(rawText)}`, res.status, []);
        }
        if (!res.ok || !envelope.success) {
            throw new CloudflareApiError(envelope.errors?.[0]?.message ?? `Cloudflare API ${method} ${path} failed`, res.status, envelope.errors ?? []);
        }
        return envelope.result;
    }
};
CloudflarePagesProvider = __decorate([
    injectable(),
    __param(0, inject('ICloudProviderTokensRepository')),
    __param(1, inject('FetchFunction')),
    __param(2, inject('ExecFunction')),
    __param(3, inject('CloudflareProviderClock')),
    __metadata("design:paramtypes", [Object, Function, Function, Object])
], CloudflarePagesProvider);
export { CloudflarePagesProvider };
function snippetForError(text, max = 200) {
    const collapsed = text.replace(/\s+/g, ' ').trim();
    if (collapsed.length === 0)
        return '<empty body>';
    if (collapsed.length <= max)
        return collapsed;
    return `${collapsed.slice(0, max)}…`;
}
function mapStageToStatus(stage) {
    switch (stage) {
        case 'success':
            return CloudDeploymentStatus.Deployed;
        case 'failure':
        case 'canceled':
            return CloudDeploymentStatus.Failed;
        case 'active':
        case 'idle':
        case 'skipped':
            return CloudDeploymentStatus.Deploying;
        default:
            return CloudDeploymentStatus.Uploading;
    }
}
