import { createHash, randomBytes, randomUUID } from "node:crypto";
export const LOCAL_UI_SCOPE_METADATA = {
    "bridge:read": {
        title: "Bridge read",
        description: "Read bridge status, workspace-scoped runtime diagnostics, run timelines, permission activity, and authority contracts.",
    },
    "bridge:review": {
        title: "Bridge review decisions",
        description: "Record human review decisions (approve, request-changes, revoke, defer) for the bound workspace's review queue, without any other write capability.",
    },
    "runs:write": {
        title: "Runs write",
        description: "Create PatchTrail-owned runs and send prompts through legacy bridge-token-authorized paths for the bound workspace.",
    },
    "runs:cancel": {
        title: "Runs cancel",
        description: "Cancel an in-flight PatchTrail-owned run for the bound workspace without affecting other runs or the global runtime.",
    },
    "runs:retry": {
        title: "Runs retry",
        description: "Retry a terminated (cancelled, failed, or completed) run by creating a new receipt-bound run from the original run's source binding.",
    },
    "run-intents:create": {
        title: "Run intent create",
        description: "Create same-machine local bridge runs only with a hosted run-intent receipt for the bound workspace.",
    },
    "run-intents:prompt": {
        title: "Run intent prompt",
        description: "Send bounded starter prompts only with the exact hosted run-intent receipt bound to the local run.",
    },
    "permissions:reply": {
        title: "Permission reply",
        description: "Reply to pending permission requests for the bound workspace without managing remembered decisions.",
    },
    "permissions:manage": {
        title: "Permission manage",
        description: "Manage remembered session permission decisions, including one-click revoke, for the bound workspace.",
    },
    "permissions:evaluate": {
        title: "Permission evaluate",
        description: "Evaluate permission requests against bridge policy without submitting a runtime reply.",
    },
    "provider-auth:read": {
        title: "Provider auth read",
        description: "Read sanitized provider credential status for the bound workspace without exposing secret material.",
    },
    "provider-auth:write": {
        title: "Provider auth write",
        description: "Store or revoke workspace-scoped provider credentials through the local bridge credential authority.",
    },
    "repository:read": {
        title: "Repository read",
        description: "Read the bound workspace's linked repositories — sanitized owner/repo coordinates, link state, and clone state — without exposing tokens or local filesystem paths.",
    },
    "repository:write": {
        title: "Repository write",
        description: "Link or unlink a remote repository, and locate or clone its local working copy within the operator-configured allowed roots, for the bound workspace. Does not grant forge write or general filesystem access.",
    },
    "forge:read": {
        title: "Forge read",
        description: "Read sanitized forge credential status and authorized repository list for the bound workspace without exposing the token.",
    },
    "forge:write": {
        title: "Forge write",
        description: "Submit run evidence as commits, draft proofs, and open draft pull requests using the locally-stored GitHub forge token. Narrow — does not grant general repository write or force-push.",
    },
    "debug:reveal": {
        title: "Debug reveal",
        description: "Activate or revoke a short-lived local-only debug reveal window for sanitized workspace diagnostics.",
    },
    "remote:control": {
        title: "Remote control",
        description: "Read and change the bound workspace's Layer 3 Remote Control policy (per-category enable + risk tier), claim a device pairing, and approve or reject parked high-risk remote commands. Does not itself execute remote commands.",
    },
};
export const LOCAL_UI_SCOPES = Object.freeze(Object.keys(LOCAL_UI_SCOPE_METADATA));
export const LOCAL_UI_SCOPE_DETAILS = Object.freeze(LOCAL_UI_SCOPES.map((scope) => ({
    scope,
    title: LOCAL_UI_SCOPE_METADATA[scope].title,
    description: LOCAL_UI_SCOPE_METADATA[scope].description,
})));
/**
 * Absolute ceiling on a single session's lifetime, measured from the moment it
 * was exchanged. Sliding-window renewal keeps an actively-used session alive,
 * but never past this cap — so even a perpetually-visible tab eventually has to
 * re-pair through the launcher. This is the "bounded lifetime" guarantee the
 * keepalive design rests on; it is independent of the per-renew window.
 */
export const ABSOLUTE_MAX_SESSION_LIFETIME_SECONDS = 43_200; // 12 hours
export class LocalUiCapabilityError extends Error {
    code;
    statusCode;
    constructor(code, message, statusCode) {
        super(message);
        this.code = code;
        this.statusCode = statusCode;
        this.name = "LocalUiCapabilityError";
    }
}
export class LocalUiCapabilityService {
    audit;
    now;
    #pendingByHash = new Map();
    #sessionsByHash = new Map();
    constructor(audit, now = () => new Date()) {
        this.audit = audit;
        this.now = now;
    }
    mint(input) {
        validateMintInput(input);
        if (!isAllowedOrigin(input.origin)) {
            throw new LocalUiCapabilityError("invalid_origin", "Local UI capabilities require an exact HTTPS or localhost origin.", 403);
        }
        const token = `pt_lui_${randomToken()}`;
        const capabilityId = `uicap_${randomUUID()}`;
        const ttlSeconds = clampSeconds(input.ttlSeconds ?? 60, 5, 300);
        const sessionTtlSeconds = clampSeconds(input.sessionTtlSeconds ?? 300, 30, 3_600);
        const expiresAt = new Date(this.now().getTime() + ttlSeconds * 1_000).toISOString();
        const scopes = uniqueScopes(input.scopes);
        const pending = {
            capabilityId,
            tokenHash: hashToken(token),
            expiresAt,
            origin: input.origin,
            workspaceId: input.workspaceId,
            deviceId: input.deviceId,
            userSessionId: input.userSessionId,
            scopes,
            sessionTtlSeconds,
        };
        this.#pendingByHash.set(pending.tokenHash, pending);
        this.audit.append({
            type: "local_ui.capability.minted",
            severity: "info",
            message: "Local UI handoff capability minted.",
            metadata: {
                capabilityId,
                origin: pending.origin,
                workspaceId: pending.workspaceId,
                deviceId: pending.deviceId,
                userSessionId: pending.userSessionId,
                scopes,
                expiresAt,
            },
        });
        return {
            capabilityId,
            token,
            expiresAt,
            origin: pending.origin,
            workspaceId: pending.workspaceId,
            deviceId: pending.deviceId,
            userSessionId: pending.userSessionId,
            scopes,
        };
    }
    exchange(input) {
        const tokenHash = hashToken(input.capabilityToken);
        const pending = this.#pendingByHash.get(tokenHash);
        if (!pending) {
            throw this.reject("invalid_capability", "Unknown local UI capability.", 401);
        }
        if (pending.consumedAt) {
            throw this.reject("capability_consumed", "Local UI capability was already consumed.", 409, pending);
        }
        if (new Date(pending.expiresAt) <= this.now()) {
            this.#pendingByHash.delete(tokenHash);
            throw this.reject("capability_expired", "Local UI capability expired.", 410, pending);
        }
        if (input.origin !== pending.origin) {
            throw this.reject("origin_mismatch", "Origin does not match local UI capability.", 403, pending);
        }
        if (input.workspaceId !== pending.workspaceId ||
            input.deviceId !== pending.deviceId ||
            input.userSessionId !== pending.userSessionId) {
            throw this.reject("invalid_capability", "Local UI capability binding did not match.", 401, pending);
        }
        pending.consumedAt = this.now().toISOString();
        this.#pendingByHash.delete(tokenHash);
        const sessionToken = `pt_lui_sess_${randomToken()}`;
        const issuedAtMs = this.now().getTime();
        const session = {
            id: `luis_${randomUUID()}`,
            tokenHash: hashToken(sessionToken),
            workspaceId: pending.workspaceId,
            origin: pending.origin,
            deviceId: pending.deviceId,
            userSessionId: pending.userSessionId,
            scopes: [...pending.scopes],
            issuedAt: new Date(issuedAtMs).toISOString(),
            ttlSeconds: pending.sessionTtlSeconds,
            expiresAt: new Date(issuedAtMs + pending.sessionTtlSeconds * 1_000).toISOString(),
        };
        this.#sessionsByHash.set(session.tokenHash, session);
        this.audit.append({
            type: "local_ui.capability.exchanged",
            severity: "info",
            message: "Local UI handoff capability exchanged for a session.",
            metadata: {
                capabilityId: pending.capabilityId,
                sessionId: session.id,
                origin: session.origin,
                workspaceId: session.workspaceId,
                deviceId: session.deviceId,
                userSessionId: session.userSessionId,
                scopes: session.scopes,
                expiresAt: session.expiresAt,
            },
        });
        return { sessionToken, session: publicSession(session) };
    }
    authenticateSession(input) {
        const stored = this.#sessionsByHash.get(hashToken(input.sessionToken));
        if (!stored) {
            throw new LocalUiCapabilityError("invalid_capability", "Unknown local UI session.", 401);
        }
        if (new Date(stored.expiresAt) <= this.now()) {
            this.#sessionsByHash.delete(stored.tokenHash);
            throw new LocalUiCapabilityError("session_expired", "Local UI session expired.", 401);
        }
        if (stored.origin !== input.origin) {
            throw new LocalUiCapabilityError("origin_mismatch", "Origin does not match local UI session.", 403);
        }
        if (!stored.scopes.includes(input.requiredScope)) {
            throw new LocalUiCapabilityError("insufficient_scope", "Local UI session does not include the required scope.", 403);
        }
        return publicSession(stored);
    }
    /**
     * Sliding-window renewal of an EXISTING valid session. It re-runs the exact
     * fail-closed checks authenticateSession uses (unknown → 401, expired →
     * delete + 401, wrong origin → 403) BEFORE moving expiry, so an already-
     * expired or borderline session can never be resurrected, and a stolen token
     * presented from a different origin cannot extend the session.
     *
     * It deliberately does NOT touch the one-time capability — there is no
     * capability involved here at all — and it keeps the same session token
     * (lookup is by hash; the token never changes). The new expiry is the
     * sliding window from now, hard-capped at issuedAt + the absolute lifetime,
     * so renewal cannot push a session past its bounded lifetime.
     */
    renewSession(input) {
        const stored = this.#sessionsByHash.get(hashToken(input.sessionToken));
        if (!stored) {
            throw new LocalUiCapabilityError("invalid_capability", "Unknown local UI session.", 401);
        }
        const nowMs = this.now().getTime();
        // Hard ceiling first: a session that has reached its absolute lifetime is
        // gone for good and must re-pair, regardless of the sliding window. This is
        // checked BEFORE the sliding-expiry branch so it surfaces the distinct,
        // honest "re-pair" reason rather than a generic "expired".
        const absoluteDeadlineMs = new Date(stored.issuedAt).getTime() +
            ABSOLUTE_MAX_SESSION_LIFETIME_SECONDS * 1_000;
        if (nowMs >= absoluteDeadlineMs) {
            this.#sessionsByHash.delete(stored.tokenHash);
            throw new LocalUiCapabilityError("session_lifetime_exceeded", "Local UI session reached its maximum lifetime; re-pair from the launcher.", 401);
        }
        // Sliding window elapsed (idle/abandoned tab) → expire, fail closed.
        if (new Date(stored.expiresAt).getTime() <= nowMs) {
            this.#sessionsByHash.delete(stored.tokenHash);
            throw new LocalUiCapabilityError("session_expired", "Local UI session expired.", 401);
        }
        if (stored.origin !== input.origin) {
            throw new LocalUiCapabilityError("origin_mismatch", "Origin does not match local UI session.", 403);
        }
        const slidExpiryMs = nowMs + stored.ttlSeconds * 1_000;
        stored.expiresAt = new Date(Math.min(slidExpiryMs, absoluteDeadlineMs)).toISOString();
        this.audit.append({
            type: "local_ui.session.renewed",
            severity: "info",
            message: "Local UI session renewed.",
            metadata: {
                sessionId: stored.id,
                origin: stored.origin,
                workspaceId: stored.workspaceId,
                deviceId: stored.deviceId,
                userSessionId: stored.userSessionId,
                expiresAt: stored.expiresAt,
            },
        });
        return publicSession(stored);
    }
    isKnownOrigin(origin) {
        if (!origin)
            return false;
        const now = this.now();
        for (const capability of this.#pendingByHash.values()) {
            if (!capability.consumedAt &&
                capability.origin === origin &&
                new Date(capability.expiresAt) > now) {
                return true;
            }
        }
        for (const session of this.#sessionsByHash.values()) {
            if (session.origin === origin && new Date(session.expiresAt) > now) {
                return true;
            }
        }
        return false;
    }
    reject(code, message, statusCode, capability) {
        this.audit.append({
            type: "local_ui.capability.rejected",
            severity: "warning",
            message,
            metadata: capability
                ? { capabilityId: capability.capabilityId, origin: capability.origin }
                : { code },
        });
        return new LocalUiCapabilityError(code, message, statusCode);
    }
}
function validateMintInput(input) {
    if (!input.origin ||
        !input.workspaceId ||
        !input.deviceId ||
        !input.userSessionId) {
        throw new LocalUiCapabilityError("invalid_request", "origin, workspaceId, deviceId, and userSessionId are required.", 400);
    }
    if (uniqueScopes(input.scopes).length < 1) {
        throw new LocalUiCapabilityError("invalid_request", "At least one local UI scope is required.", 400);
    }
}
export function isLocalUiScope(value) {
    return (typeof value === "string" &&
        Object.prototype.hasOwnProperty.call(LOCAL_UI_SCOPE_METADATA, value));
}
function uniqueScopes(scopes) {
    return [...new Set(scopes.filter(isLocalUiScope))];
}
function isAllowedOrigin(origin) {
    try {
        const url = new URL(origin);
        if (url.protocol === "https:")
            return true;
        return (url.protocol === "http:" &&
            ["localhost", "127.0.0.1", "::1"].includes(url.hostname));
    }
    catch {
        return false;
    }
}
function publicSession(session) {
    return {
        id: session.id,
        workspaceId: session.workspaceId,
        origin: session.origin,
        deviceId: session.deviceId,
        userSessionId: session.userSessionId,
        scopes: [...session.scopes],
        expiresAt: session.expiresAt,
    };
}
function randomToken() {
    return randomBytes(32).toString("base64url");
}
function hashToken(token) {
    return createHash("sha256").update(token, "utf8").digest("base64url");
}
function clampSeconds(value, min, max) {
    if (!Number.isFinite(value))
        return min;
    return Math.max(min, Math.min(max, Math.trunc(value)));
}
