import { randomUUID } from "node:crypto";
import { RepoPathPolicyError } from "./RepoPathPolicy.js";
export class RepositoryLinkError extends Error {
    reasonCode;
    constructor(reasonCode) {
        super(reasonCode);
        this.name = "RepositoryLinkError";
        this.reasonCode = reasonCode;
    }
}
export class RepositoryLinkService {
    #store;
    #audit;
    #pathPolicy;
    #clone;
    #resolveForgeToken;
    constructor(options) {
        this.#store = options.store;
        this.#audit = options.audit;
        this.#pathPolicy = options.pathPolicy;
        this.#clone = options.cloneManager;
        this.#resolveForgeToken = options.resolveForgeToken;
    }
    list(workspaceId) {
        return this.#store.listRepositoryBindings(workspaceId);
    }
    async link(input) {
        const parsed = parseRemoteUrl(input.remoteUrl);
        if (!parsed) {
            throw new RepositoryLinkError("repo-remote-url-invalid");
        }
        const id = `rb_${randomUUID()}`;
        const displayLabel = boundedLabel(input.displayLabel ?? `${parsed.owner}/${parsed.repo}`);
        // Ensure the workspace shell exists before inserting the FK-bound binding.
        // A linked session is already authorized for this workspace; the row may
        // not yet exist if the user links before any pairing/run created it. An
        // existing root_path is preserved (upsert keeps it when none is supplied).
        const existingWorkspace = this.#store.findWorkspace(input.workspaceId);
        this.#store.upsertWorkspace({
            id: input.workspaceId,
            rootPath: existingWorkspace?.rootPath ?? "",
        });
        this.#store.upsertRepositoryBinding({
            id,
            workspaceId: input.workspaceId,
            displayLabel,
            provider: parsed.provider,
            status: "needs-action",
            remoteUrl: parsed.normalizedUrl,
            owner: parsed.owner,
            repo: parsed.repo,
            linkState: "linked",
            cloneState: "idle",
        });
        this.#audit.append({
            type: "repository.linked",
            severity: "info",
            message: `Repository ${parsed.owner}/${parsed.repo} linked.`,
            metadata: {
                bindingId: id,
                workspaceId: input.workspaceId,
                owner: parsed.owner,
                repo: parsed.repo,
                host: parsed.host,
            },
        });
        return this.#requireBinding(input.workspaceId, id);
    }
    async unlink(input) {
        const removed = this.#store.deleteRepositoryBinding(input.workspaceId, input.bindingId);
        if (removed) {
            this.#audit.append({
                type: "repository.unlinked",
                severity: "info",
                message: "Repository binding removed (working copy left on disk).",
                metadata: {
                    bindingId: input.bindingId,
                    workspaceId: input.workspaceId,
                },
            });
        }
        return removed;
    }
    async clone(input) {
        const binding = this.#requireBinding(input.workspaceId, input.bindingId);
        if (!binding.remoteUrl) {
            throw new RepositoryLinkError("repo-remote-url-invalid");
        }
        if (binding.cloneState === "cloning") {
            throw new RepositoryLinkError("repo-clone-in-progress");
        }
        // Validate the destination against the allow-list BEFORE any state change or
        // git invocation (fail-closed when no roots are configured).
        let target;
        try {
            target = this.#pathPolicy.resolveCloneTarget(input.targetDir);
        }
        catch (error) {
            throw toLinkError(error);
        }
        this.#store.updateRepositoryWorkingCopy({
            id: binding.id,
            workspaceId: input.workspaceId,
            cloneState: "cloning",
            status: "syncing",
        });
        const token = binding.remoteUrl.startsWith("https://")
            ? await this.#resolveForgeToken()
            : null;
        const result = await this.#clone.clone({
            remoteUrl: binding.remoteUrl,
            targetDir: target,
            ...(token ? { token } : {}),
            ...(binding.defaultBranch
                ? { defaultBranch: binding.defaultBranch }
                : {}),
        });
        if (!result.ok) {
            this.#store.updateRepositoryWorkingCopy({
                id: binding.id,
                workspaceId: input.workspaceId,
                cloneState: "failed",
                status: "blocked",
                lastCloneErrorCode: result.errorCode ?? "clone-failed",
            });
            this.#audit.append({
                type: "repository.clone_failed",
                severity: "warning",
                message: `Repository clone failed (${result.errorCode ?? "clone-failed"}).`,
                metadata: {
                    bindingId: binding.id,
                    workspaceId: input.workspaceId,
                    reasonCode: result.errorCode ?? "clone-failed",
                },
            });
            throw new RepositoryLinkError((result.errorCode ?? "clone-failed"));
        }
        this.#bindWorkingCopy({
            binding,
            workspaceId: input.workspaceId,
            localPath: target,
            defaultBranch: result.defaultBranch,
            auditType: "repository.cloned",
            auditMessage: `Repository cloned to working copy.`,
        });
        return this.#requireBinding(input.workspaceId, binding.id);
    }
    async locate(input) {
        const binding = this.#requireBinding(input.workspaceId, input.bindingId);
        if (!binding.remoteUrl) {
            throw new RepositoryLinkError("repo-remote-url-invalid");
        }
        let realPath;
        try {
            realPath = this.#pathPolicy.assertExistingWorkingCopy(input.localPath);
        }
        catch (error) {
            throw toLinkError(error);
        }
        const result = await this.#clone.locate({
            localPath: realPath,
            expectedRemoteUrl: binding.remoteUrl,
        });
        if (!result.matched) {
            this.#audit.append({
                type: "repository.locate_failed",
                severity: "warning",
                message: `Repository locate failed (${result.reasonCode ?? "locate-failed"}).`,
                metadata: {
                    bindingId: binding.id,
                    workspaceId: input.workspaceId,
                    reasonCode: result.reasonCode ?? "locate-failed",
                },
            });
            throw new RepositoryLinkError((result.reasonCode ?? "locate-failed"));
        }
        this.#bindWorkingCopy({
            binding,
            workspaceId: input.workspaceId,
            localPath: realPath,
            defaultBranch: result.defaultBranch,
            auditType: "repository.located",
            auditMessage: `Existing working copy located and bound.`,
        });
        return this.#requireBinding(input.workspaceId, binding.id);
    }
    #bindWorkingCopy(args) {
        this.#store.updateRepositoryWorkingCopy({
            id: args.binding.id,
            workspaceId: args.workspaceId,
            cloneState: "cloned",
            status: "connected",
            localPath: args.localPath,
            lastCloneErrorCode: null,
        });
        if (args.defaultBranch) {
            const current = this.#store.findRepositoryBinding(args.workspaceId, args.binding.id);
            if (current && current.defaultBranch !== args.defaultBranch) {
                this.#store.upsertRepositoryBinding({
                    ...current,
                    defaultBranch: args.defaultBranch,
                });
            }
        }
        // Auto-bind the workspace root to the working copy so runs (evidence
        // capture + OpenCode session directory) execute inside the repo.
        const workspace = this.#store.findWorkspace(args.workspaceId);
        this.#store.upsertWorkspace({
            id: args.workspaceId,
            rootPath: args.localPath,
        });
        void workspace;
        this.#audit.append({
            type: args.auditType,
            severity: "info",
            // The local path is NEVER included in the audit metadata (forbidden
            // public surface); only sanitized identifiers + the bound flag.
            message: args.auditMessage,
            metadata: {
                bindingId: args.binding.id,
                workspaceId: args.workspaceId,
                cloneState: "cloned",
                workspaceRootBound: true,
            },
        });
    }
    #requireBinding(workspaceId, bindingId) {
        const binding = this.#store.findRepositoryBinding(workspaceId, bindingId);
        if (!binding) {
            throw new RepositoryLinkError("repo-binding-not-found");
        }
        return binding;
    }
}
const MAX_LABEL = 120;
function boundedLabel(value) {
    const trimmed = value.trim();
    if (!trimmed)
        return "repository";
    return trimmed.length > MAX_LABEL ? trimmed.slice(0, MAX_LABEL) : trimmed;
}
const PROVIDER_BY_HOST = {
    "github.com": "github",
    "www.github.com": "github",
    "gitlab.com": "gitlab",
    "bitbucket.org": "bitbucket",
};
/**
 * Parse + sanitize a user-supplied remote URL into stable coordinates. Accepts
 * https and scp/ssh forms. REJECTS any URL carrying embedded credentials (a
 * token in the URL would otherwise be stored + logged). Returns null on any
 * parse failure or when owner/repo cannot be determined.
 */
export function parseRemoteUrl(remoteUrl) {
    const raw = typeof remoteUrl === "string" ? remoteUrl.trim() : "";
    if (!raw || raw.length > 512)
        return null;
    let host = "";
    let pathname = "";
    const scp = /^([^/@]+)@([^:/]+):(.+)$/u.exec(raw);
    if (scp && !raw.includes("://")) {
        // scp-like ssh: user@host:owner/repo(.git). The user part is a git login
        // (e.g. "git"), not a credential we store — but we still normalize to https.
        host = scp[2] ?? "";
        pathname = scp[3] ?? "";
    }
    else {
        let url;
        try {
            url = new URL(raw);
        }
        catch {
            return null;
        }
        // file:// remotes (local bare repos) are allowed: the clone destination is
        // still allow-list bounded, and no forge token is ever attached to a
        // non-https remote. They are kept verbatim (no https rewrite) and provider
        // 'unknown'.
        if (url.protocol === "file:") {
            return parseFileRemote(url);
        }
        if (url.protocol !== "https:" && url.protocol !== "ssh:") {
            return null;
        }
        // Embedded credentials are forbidden — they would be persisted/logged.
        if (url.username || url.password) {
            return null;
        }
        host = url.hostname;
        pathname = url.pathname;
    }
    const segments = pathname
        .replace(/^\/+/u, "")
        .replace(/\.git$/iu, "")
        .replace(/\/+$/u, "")
        .split("/")
        .filter((segment) => segment.length > 0);
    if (host.length === 0 || segments.length < 2)
        return null;
    const owner = segments[0] ?? "";
    const repo = segments.slice(1).join("/");
    if (!isSafeCoordinate(owner) || !isSafeCoordinate(repo))
        return null;
    return {
        host: host.toLowerCase(),
        owner,
        repo,
        normalizedUrl: `https://${host.toLowerCase()}/${owner}/${repo}.git`,
        provider: PROVIDER_BY_HOST[host.toLowerCase()] ?? "unknown",
    };
}
function parseFileRemote(url) {
    const segments = url.pathname
        .replace(/\.git$/iu, "")
        .replace(/\/+$/u, "")
        .split("/")
        .filter((segment) => segment.length > 0);
    const repo = segments.at(-1);
    if (!repo)
        return null;
    const owner = segments.at(-2) ?? "local";
    return {
        host: "file",
        owner,
        repo,
        // Preserve the exact file URL so locate's origin comparison round-trips.
        normalizedUrl: url.href,
        provider: "unknown",
    };
}
function isSafeCoordinate(value) {
    return (value.length > 0 &&
        value.length <= 100 &&
        /^[A-Za-z0-9._/-]+$/u.test(value) &&
        !value.includes(".."));
}
function toLinkError(error) {
    if (error instanceof RepoPathPolicyError) {
        return new RepositoryLinkError(error.reasonCode);
    }
    if (error instanceof RepositoryLinkError) {
        return error;
    }
    return new RepositoryLinkError("clone-failed");
}
