import { spawn } from "node:child_process";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import type { CredentialReferenceId } from "@awp/contracts";
import {
  candidateManifestEquals,
  createCandidateManifest,
  type CandidateManifest,
} from "@awp/domain";
import type {
  GitHubMergeState,
  GitHubPublicationState,
  GitHubRepositoryState,
  GitHubTrustedClient,
} from "./client-types.js";

const GITHUB_API_VERSION = "2022-11-28";
const NATIVE_PREFIX = "awp-github-v1:";

export interface GitHubCredentialLease {
  readonly token: string;
  dispose(): void;
}

export interface GitHubCredentialResolver {
  resolve(referenceId: CredentialReferenceId, purpose: string): Promise<GitHubCredentialLease>;
}

interface FetchResponse {
  readonly ok: boolean;
  readonly status: number;
  readonly statusText: string;
  text(): Promise<string>;
}

export type GitHubFetch = (
  url: string,
  init: {
    method: string;
    headers: Readonly<Record<string, string>>;
    body?: string;
  },
) => Promise<FetchResponse>;

export class GitHubHttpError extends Error {
  constructor(
    readonly status: number,
    message: string,
  ) {
    super(message);
    this.name = "GitHubHttpError";
  }
}

export interface GitHubRepositoryIdentity {
  readonly repositoryKey: string;
  readonly owner: string;
  readonly repository: string;
}

export function parseGitHubRepositoryUrl(source: string): GitHubRepositoryIdentity {
  const value = source.trim();
  let owner: string | undefined;
  let repository: string | undefined;

  const ssh = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/iu.exec(value);
  if (ssh) {
    owner = ssh[1];
    repository = ssh[2];
  } else {
    let url: URL;
    try {
      url = new URL(value);
    } catch {
      throw new Error("Project repository must be a canonical GitHub HTTPS or SSH URL");
    }
    if (
      url.protocol !== "https:" ||
      url.hostname.toLowerCase() !== "github.com" ||
      url.username ||
      url.password ||
      url.port ||
      url.search ||
      url.hash
    ) {
      throw new Error(
        "Project repository must use canonical credential-free GitHub HTTPS/SSH authority",
      );
    }
    const parts = url.pathname.replace(/^\/+|\/+$/gu, "").split("/");
    if (parts.length !== 2 || !parts[0] || !parts[1]) {
      throw new Error("Project GitHub repository URL must identify exactly owner/repository");
    }
    owner = parts[0];
    repository = parts[1].replace(/\.git$/iu, "");
  }

  if (
    !owner ||
    !repository ||
    !/^[A-Za-z0-9_.-]+$/u.test(owner) ||
    !/^[A-Za-z0-9_.-]+$/u.test(repository)
  ) {
    throw new Error("Project GitHub repository identity contains unsupported characters");
  }
  return { owner, repository, repositoryKey: `${owner}/${repository}` };
}

function parseRepositoryKey(repositoryKey: string): GitHubRepositoryIdentity {
  const parts = repositoryKey.trim().split("/");
  if (
    parts.length !== 2 ||
    !parts[0] ||
    !parts[1] ||
    !/^[A-Za-z0-9_.-]+$/u.test(parts[0]) ||
    !/^[A-Za-z0-9_.-]+$/u.test(parts[1])
  ) {
    throw new Error("GitHub repository key must be owner/repository");
  }
  return { owner: parts[0], repository: parts[1], repositoryKey: `${parts[0]}/${parts[1]}` };
}

function parseObject(source: string): Readonly<Record<string, unknown>> {
  if (!source) return {};
  const parsed: unknown = JSON.parse(source);
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
    throw new Error("GitHub API returned a non-object payload");
  }
  return parsed as Readonly<Record<string, unknown>>;
}

function fieldString(object: Readonly<Record<string, unknown>>, key: string): string {
  const value = object[key];
  if (typeof value !== "string" || !value.trim())
    throw new Error(`GitHub payload is missing ${key}`);
  return value;
}

function optionalString(
  object: Readonly<Record<string, unknown>>,
  key: string,
): string | undefined {
  const value = object[key];
  return typeof value === "string" && value.trim() ? value : undefined;
}

function encodeNative(value: Readonly<Record<string, unknown>>): string {
  return `${NATIVE_PREFIX}${Buffer.from(JSON.stringify(value)).toString("base64url")}`;
}

function decodeNative(nativeId: string): Readonly<Record<string, unknown>> {
  if (!nativeId.startsWith(NATIVE_PREFIX)) throw new Error("Invalid AWP GitHub native identity");
  try {
    return parseObject(
      Buffer.from(nativeId.slice(NATIVE_PREFIX.length), "base64url").toString("utf8"),
    );
  } catch (error) {
    throw new Error("Invalid AWP GitHub native identity", { cause: error });
  }
}

function branchFromRef(ref: string): string {
  if (!ref.startsWith("refs/heads/") || ref.length <= "refs/heads/".length) {
    throw new Error("GitHub target reference must be refs/heads/<branch>");
  }
  return ref.slice("refs/heads/".length);
}

function refApiPath(ref: string): string {
  return ref.replace(/^refs\//u, "");
}

function safeGitHubMessage(status: number, body: string): string {
  try {
    const parsed = parseObject(body);
    if (typeof parsed.message === "string") return `GitHub API ${status}: ${parsed.message}`;
  } catch {
    // Do not echo arbitrary provider bodies.
  }
  return `GitHub API request failed with status ${status}`;
}

interface CommitMetadata {
  readonly changeSetId: string;
  readonly factoryRunId: string;
  readonly idempotencyKey: string;
  readonly candidateManifest: CandidateManifest;
}

function commitMessage(metadata: CommitMetadata): string {
  return [
    `AWP ChangeSet ${metadata.changeSetId}`,
    "",
    `AWP-ChangeSet: ${metadata.changeSetId}`,
    `AWP-FactoryRun: ${metadata.factoryRunId}`,
    `AWP-Idempotency-Key: ${Buffer.from(metadata.idempotencyKey).toString("base64url")}`,
    `AWP-Candidate-Manifest: ${Buffer.from(JSON.stringify(metadata.candidateManifest)).toString("base64url")}`,
  ].join("\n");
}

function parseCommitMetadata(message: string): CommitMetadata {
  const changeSet = /^AWP-ChangeSet:\s*(.+)$/gmu.exec(message)?.[1]?.trim();
  const factoryRun = /^AWP-FactoryRun:\s*(.+)$/gmu.exec(message)?.[1]?.trim();
  const encodedIdempotency = /^AWP-Idempotency-Key:\s*([A-Za-z0-9_-]+)$/gmu.exec(message)?.[1];
  const encodedManifest = /^AWP-Candidate-Manifest:\s*([A-Za-z0-9_-]+)$/gmu.exec(message)?.[1];
  if (!changeSet || !factoryRun || !encodedIdempotency || !encodedManifest) {
    throw new Error("GitHub publication commit is missing AWP fidelity metadata");
  }
  const idempotencyKey = Buffer.from(encodedIdempotency, "base64url").toString("utf8");
  const rawManifest: unknown = JSON.parse(
    Buffer.from(encodedManifest, "base64url").toString("utf8"),
  );
  if (!rawManifest || typeof rawManifest !== "object" || Array.isArray(rawManifest)) {
    throw new Error("GitHub publication commit has invalid AWP candidate metadata");
  }
  return {
    changeSetId: changeSet,
    factoryRunId: factoryRun,
    idempotencyKey,
    candidateManifest: createCandidateManifest(rawManifest as CandidateManifest),
  };
}

interface GitTreeEntry {
  readonly path: string;
  readonly mode: string;
  readonly type: string;
  readonly sha: string;
}

function safeCandidatePath(path: string): void {
  if (
    !path ||
    path.startsWith("/") ||
    path.includes("\\") ||
    path.split("/").some((segment) => !segment || segment === "." || segment === "..")
  ) {
    throw new Error(`Unsafe candidate path: ${path}`);
  }
}

async function applyPatch(root: string, diff: string): Promise<void> {
  if (/^GIT binary patch$/mu.test(diff) || /\0/u.test(diff)) {
    throw new Error("I1 trusted GitHub publication does not accept binary patches");
  }
  for (const args of [
    ["apply", "--check", "--", "-"],
    ["apply", "--", "-"],
  ] as const) {
    await new Promise<void>((resolve, reject) => {
      const child = spawn("git", [...args], { cwd: root, stdio: ["pipe", "ignore", "pipe"] });
      let stderr = "";
      child.stderr.setEncoding("utf8");
      child.stderr.on("data", (chunk: string) => {
        stderr += chunk;
      });
      child.once("error", reject);
      child.once("close", (code) => {
        if (code === 0) resolve();
        else
          reject(
            new Error(
              `Candidate patch does not apply to the exact GitHub base (git apply ${code}): ${stderr.trim()}`,
            ),
          );
      });
      child.stdin.end(diff);
    });
  }
}

export class GitHubTrustedHttpClient implements GitHubTrustedClient {
  constructor(
    private readonly credentials: GitHubCredentialResolver,
    private readonly fetcher: GitHubFetch = globalThis.fetch as unknown as GitHubFetch,
    private readonly apiBase = "https://api.github.com",
  ) {}

  async inspectRepository(
    repositoryKey: string,
    credentialReferenceId: CredentialReferenceId,
  ): Promise<GitHubRepositoryState> {
    const repo = parseRepositoryKey(repositoryKey);
    const record = parseObject(
      await this.request(
        credentialReferenceId,
        "vcs.inspect",
        "GET",
        this.repoUrl(repo),
        undefined,
        [200],
      ),
    );
    const defaultBranch = fieldString(record, "default_branch");
    const headRevision = await this.readRefSha(
      repo,
      `refs/heads/${defaultBranch}`,
      credentialReferenceId,
    );
    if (!headRevision) throw new GitHubHttpError(404, "GitHub default branch reference is missing");
    const id = record.id;
    if (typeof id !== "number" && typeof id !== "string")
      throw new Error("GitHub repository payload is missing id");
    const url = optionalString(record, "html_url");
    return {
      repositoryKey: repo.repositoryKey,
      nativeId: String(id),
      defaultBranch,
      headRevision,
      ...(url === undefined ? {} : { url }),
    };
  }

  async findPublicationByIdempotencyKey(input: {
    readonly repositoryKey: string;
    readonly targetRef: string;
    readonly idempotencyKey: string;
    readonly credentialReferenceId: CredentialReferenceId;
  }): Promise<GitHubPublicationState | undefined> {
    const repo = parseRepositoryKey(input.repositoryKey);
    const revision = await this.readRefSha(repo, input.targetRef, input.credentialReferenceId);
    if (!revision) return undefined;
    const state = await this.publicationState(
      repo,
      input.targetRef,
      revision,
      input.credentialReferenceId,
    );
    return state.idempotencyKey === input.idempotencyKey ? state : state;
  }

  async publishCandidate(input: {
    readonly repositoryKey: string;
    readonly targetRef: string;
    readonly expectedBaseRevision: string;
    readonly candidateDigest: string;
    readonly candidateManifest: CandidateManifest;
    readonly diff: string;
    readonly changeSetId: string;
    readonly factoryRunId: string;
    readonly idempotencyKey: string;
    readonly credentialReferenceId: CredentialReferenceId;
  }): Promise<GitHubPublicationState> {
    const repo = parseRepositoryKey(input.repositoryKey);
    const manifest = createCandidateManifest(input.candidateManifest);
    if (manifest.treeDigest !== input.candidateDigest)
      throw new Error("Candidate tree identity mismatch");
    for (const path of manifest.changedPaths) safeCandidatePath(path);

    const existing = await this.findPublicationByIdempotencyKey({
      repositoryKey: repo.repositoryKey,
      targetRef: input.targetRef,
      idempotencyKey: input.idempotencyKey,
      credentialReferenceId: input.credentialReferenceId,
    });
    if (existing) return existing;

    const baseCommit = parseObject(
      await this.request(
        input.credentialReferenceId,
        "vcs.publish.base",
        "GET",
        `${this.repoUrl(repo)}/git/commits/${encodeURIComponent(input.expectedBaseRevision)}`,
        undefined,
        [200],
      ),
    );
    const baseTreeObject = baseCommit.tree;
    if (!baseTreeObject || typeof baseTreeObject !== "object" || Array.isArray(baseTreeObject)) {
      throw new Error("GitHub base commit is missing tree identity");
    }
    const baseTreeSha = fieldString(baseTreeObject as Readonly<Record<string, unknown>>, "sha");
    const treePayload = parseObject(
      await this.request(
        input.credentialReferenceId,
        "vcs.publish.base-tree",
        "GET",
        `${this.repoUrl(repo)}/git/trees/${encodeURIComponent(baseTreeSha)}?recursive=1`,
        undefined,
        [200],
      ),
    );
    if (treePayload.truncated === true)
      throw new Error("GitHub base tree is truncated; publication fails closed");
    const rawTree = Array.isArray(treePayload.tree) ? treePayload.tree : [];
    const baseEntries = new Map<string, GitTreeEntry>();
    for (const raw of rawTree) {
      if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
      const item = raw as Readonly<Record<string, unknown>>;
      if (
        typeof item.path !== "string" ||
        typeof item.mode !== "string" ||
        typeof item.type !== "string" ||
        typeof item.sha !== "string"
      )
        continue;
      baseEntries.set(item.path, {
        path: item.path,
        mode: item.mode,
        type: item.type,
        sha: item.sha,
      });
    }

    const root = await mkdtemp(join(tmpdir(), "awp-github-publish-"));
    try {
      for (const change of manifest.changes) {
        safeCandidatePath(change.path);
        const base = baseEntries.get(change.path);
        if (change.kind === "add") {
          if (base) throw new Error(`Candidate declares add for existing base path ${change.path}`);
          continue;
        }
        if (!base) throw new Error(`Candidate base path is missing on GitHub: ${change.path}`);
        if (base.type !== "blob" || !["100644", "100755"].includes(base.mode)) {
          throw new Error(`Candidate path uses unsupported Git object mode: ${change.path}`);
        }
        const blob = parseObject(
          await this.request(
            input.credentialReferenceId,
            "vcs.publish.base-blob",
            "GET",
            `${this.repoUrl(repo)}/git/blobs/${encodeURIComponent(base.sha)}`,
            undefined,
            [200],
          ),
        );
        if (fieldString(blob, "encoding") !== "base64")
          throw new Error("GitHub base blob is not base64 encoded");
        const content = fieldString(blob, "content").replace(/\s+/gu, "");
        const absolute = join(root, change.path);
        await mkdir(dirname(absolute), { recursive: true });
        await writeFile(absolute, Buffer.from(content, "base64"));
      }

      await applyPatch(root, input.diff);
      const entries: Readonly<Record<string, unknown>>[] = [];
      for (const change of manifest.changes) {
        const base = baseEntries.get(change.path);
        if (change.kind === "delete") {
          entries.push({ path: change.path, mode: base!.mode, type: "blob", sha: null });
          continue;
        }
        const bytes = await readFile(join(root, change.path));
        const blob = parseObject(
          await this.request(
            input.credentialReferenceId,
            "vcs.publish.create-blob",
            "POST",
            `${this.repoUrl(repo)}/git/blobs`,
            { content: bytes.toString("base64"), encoding: "base64" },
            [201],
          ),
        );
        entries.push({
          path: change.path,
          mode: base?.mode ?? "100644",
          type: "blob",
          sha: fieldString(blob, "sha"),
        });
      }

      const createdTree = parseObject(
        await this.request(
          input.credentialReferenceId,
          "vcs.publish.create-tree",
          "POST",
          `${this.repoUrl(repo)}/git/trees`,
          { base_tree: baseTreeSha, tree: entries },
          [201],
        ),
      );
      const createdTreeSha = fieldString(createdTree, "sha");
      if (createdTreeSha !== input.candidateDigest) {
        throw new GitHubHttpError(
          409,
          "GitHub reconstructed tree does not match the immutable ChangeSet candidate",
        );
      }

      const createdCommit = parseObject(
        await this.request(
          input.credentialReferenceId,
          "vcs.publish.create-commit",
          "POST",
          `${this.repoUrl(repo)}/git/commits`,
          {
            message: commitMessage({
              changeSetId: input.changeSetId,
              factoryRunId: input.factoryRunId,
              idempotencyKey: input.idempotencyKey,
              candidateManifest: manifest,
            }),
            tree: createdTreeSha,
            parents: [input.expectedBaseRevision],
          },
          [201],
        ),
      );
      const commitSha = fieldString(createdCommit, "sha");
      try {
        await this.request(
          input.credentialReferenceId,
          "vcs.publish.create-ref",
          "POST",
          `${this.repoUrl(repo)}/git/refs`,
          { ref: input.targetRef, sha: commitSha },
          [201],
        );
      } catch (error) {
        if (!(error instanceof GitHubHttpError) || error.status !== 422) throw error;
      }
      const readBack = await this.readPublication(
        encodeNative({
          kind: "publication",
          repositoryKey: repo.repositoryKey,
          targetRef: input.targetRef,
        }),
        input.credentialReferenceId,
      );
      if (!readBack) throw new Error("GitHub publication branch could not be read back");
      return readBack;
    } finally {
      await rm(root, { recursive: true, force: true });
    }
  }

  async readPublication(
    nativeId: string,
    credentialReferenceId: CredentialReferenceId,
  ): Promise<GitHubPublicationState | undefined> {
    const identity = decodeNative(nativeId);
    if (identity.kind !== "publication")
      throw new Error("GitHub native identity is not a publication");
    const repositoryKey = fieldString(identity, "repositoryKey");
    const targetRef = fieldString(identity, "targetRef");
    const repo = parseRepositoryKey(repositoryKey);
    const revision = await this.readRefSha(repo, targetRef, credentialReferenceId);
    return revision
      ? this.publicationState(repo, targetRef, revision, credentialReferenceId)
      : undefined;
  }

  async findMergeByIdempotencyKey(input: {
    readonly repositoryKey: string;
    readonly targetRef: string;
    readonly expectedTargetRevision: string;
    readonly expectedBaseRevision: string;
    readonly publicationNativeId: string;
    readonly candidateDigest: string;
    readonly candidateManifest: CandidateManifest;
    readonly idempotencyKey: string;
    readonly credentialReferenceId: CredentialReferenceId;
  }): Promise<GitHubMergeState | undefined> {
    const repo = parseRepositoryKey(input.repositoryKey);
    const publication = await this.readPublication(
      input.publicationNativeId,
      input.credentialReferenceId,
    );
    if (!publication) return undefined;
    if (
      publication.repositoryKey !== repo.repositoryKey ||
      publication.baseRevision !== input.expectedBaseRevision ||
      publication.candidateDigest !== input.candidateDigest ||
      !candidateManifestEquals(publication.candidateManifest, input.candidateManifest)
    )
      return undefined;
    const currentTarget = await this.readRefSha(repo, input.targetRef, input.credentialReferenceId);
    if (!currentTarget || currentTarget === input.expectedTargetRevision) return undefined;
    const comparison = parseObject(
      await this.request(
        input.credentialReferenceId,
        "vcs.merge.reconcile",
        "GET",
        `${this.repoUrl(repo)}/compare/${encodeURIComponent(publication.targetRevision)}...${encodeURIComponent(currentTarget)}`,
        undefined,
        [200],
      ),
    );
    const status = optionalString(comparison, "status");
    if (status !== "ahead" && status !== "identical") return undefined;
    return this.mergeState(input, currentTarget);
  }

  async mergePublication(input: {
    readonly repositoryKey: string;
    readonly targetRef: string;
    readonly expectedTargetRevision: string;
    readonly expectedBaseRevision: string;
    readonly publicationNativeId: string;
    readonly candidateDigest: string;
    readonly candidateManifest: CandidateManifest;
    readonly idempotencyKey: string;
    readonly credentialReferenceId: CredentialReferenceId;
  }): Promise<GitHubMergeState> {
    const existing = await this.findMergeByIdempotencyKey(input);
    if (existing) return existing;
    const repo = parseRepositoryKey(input.repositoryKey);
    const publication = await this.readPublication(
      input.publicationNativeId,
      input.credentialReferenceId,
    );
    if (!publication) throw new GitHubHttpError(404, "GitHub publication no longer exists");
    const currentTarget = await this.readRefSha(repo, input.targetRef, input.credentialReferenceId);
    if (currentTarget !== input.expectedTargetRevision) {
      throw new GitHubHttpError(409, "GitHub merge target moved since approval");
    }
    const branch = branchFromRef(input.targetRef);
    const resultText = await this.request(
      input.credentialReferenceId,
      "vcs.merge",
      "POST",
      `${this.repoUrl(repo)}/merges`,
      {
        base: branch,
        head: publication.targetRevision,
        commit_message: `AWP trusted merge ${input.idempotencyKey}`,
      },
      [201, 204],
    );
    let resultingRevision: string;
    if (resultText) {
      resultingRevision = fieldString(parseObject(resultText), "sha");
    } else {
      const reread = await this.readRefSha(repo, input.targetRef, input.credentialReferenceId);
      if (!reread) throw new Error("GitHub merge target disappeared after merge");
      resultingRevision = reread;
    }
    const resultingCommit = parseObject(
      await this.request(
        input.credentialReferenceId,
        "vcs.merge.read-back",
        "GET",
        `${this.repoUrl(repo)}/git/commits/${encodeURIComponent(resultingRevision)}`,
        undefined,
        [200],
      ),
    );
    const tree = resultingCommit.tree;
    if (!tree || typeof tree !== "object" || Array.isArray(tree))
      throw new Error("GitHub merge commit is missing tree");
    if (fieldString(tree as Readonly<Record<string, unknown>>, "sha") !== input.candidateDigest) {
      throw new GitHubHttpError(
        409,
        "GitHub merge result does not preserve the approved candidate tree",
      );
    }
    const targetAfter = await this.readRefSha(repo, input.targetRef, input.credentialReferenceId);
    if (targetAfter !== resultingRevision)
      throw new GitHubHttpError(409, "GitHub target moved during merge read-back");
    return this.mergeState(input, resultingRevision);
  }

  async readMerge(
    nativeId: string,
    credentialReferenceId: CredentialReferenceId,
  ): Promise<GitHubMergeState | undefined> {
    const identity = decodeNative(nativeId);
    if (identity.kind !== "merge") throw new Error("GitHub native identity is not a merge");
    const candidateManifestRaw = identity.candidateManifest;
    if (
      !candidateManifestRaw ||
      typeof candidateManifestRaw !== "object" ||
      Array.isArray(candidateManifestRaw)
    ) {
      throw new Error("GitHub merge identity is missing candidate manifest");
    }
    return this.findMergeByIdempotencyKey({
      repositoryKey: fieldString(identity, "repositoryKey"),
      targetRef: fieldString(identity, "targetRef"),
      expectedTargetRevision: fieldString(identity, "expectedTargetRevision"),
      expectedBaseRevision: fieldString(identity, "expectedBaseRevision"),
      publicationNativeId: fieldString(identity, "publicationNativeId"),
      candidateDigest: fieldString(identity, "candidateDigest"),
      candidateManifest: createCandidateManifest(candidateManifestRaw as CandidateManifest),
      idempotencyKey: fieldString(identity, "idempotencyKey"),
      credentialReferenceId,
    });
  }

  private mergeState(
    input: {
      readonly repositoryKey: string;
      readonly targetRef: string;
      readonly expectedTargetRevision: string;
      readonly expectedBaseRevision: string;
      readonly publicationNativeId: string;
      readonly candidateDigest: string;
      readonly candidateManifest: CandidateManifest;
      readonly idempotencyKey: string;
    },
    resultingRevision: string,
  ): GitHubMergeState {
    const repo = parseRepositoryKey(input.repositoryKey);
    const nativeId = encodeNative({
      kind: "merge",
      repositoryKey: repo.repositoryKey,
      targetRef: input.targetRef,
      expectedTargetRevision: input.expectedTargetRevision,
      expectedBaseRevision: input.expectedBaseRevision,
      publicationNativeId: input.publicationNativeId,
      candidateDigest: input.candidateDigest,
      candidateManifest: input.candidateManifest,
      idempotencyKey: input.idempotencyKey,
    });
    return {
      nativeId,
      repositoryKey: repo.repositoryKey,
      targetRef: input.targetRef,
      expectedTargetRevision: input.expectedTargetRevision,
      expectedBaseRevision: input.expectedBaseRevision,
      publicationNativeId: input.publicationNativeId,
      candidateDigest: input.candidateDigest,
      candidateManifest: createCandidateManifest(input.candidateManifest),
      resultingRevision,
      status: "merged",
      url: `https://github.com/${repo.repositoryKey}/commit/${resultingRevision}`,
    };
  }

  private async publicationState(
    repo: GitHubRepositoryIdentity,
    targetRef: string,
    revision: string,
    credentialReferenceId: CredentialReferenceId,
  ): Promise<GitHubPublicationState> {
    const commit = parseObject(
      await this.request(
        credentialReferenceId,
        "vcs.publication.read",
        "GET",
        `${this.repoUrl(repo)}/git/commits/${encodeURIComponent(revision)}`,
        undefined,
        [200],
      ),
    );
    const tree = commit.tree;
    if (!tree || typeof tree !== "object" || Array.isArray(tree))
      throw new Error("GitHub publication commit is missing tree");
    const parents = Array.isArray(commit.parents) ? commit.parents : [];
    const parent = parents[0];
    if (!parent || typeof parent !== "object" || Array.isArray(parent))
      throw new Error("GitHub publication commit is missing base parent");
    const metadata = parseCommitMetadata(fieldString(commit, "message"));
    const candidateDigest = fieldString(tree as Readonly<Record<string, unknown>>, "sha");
    if (metadata.candidateManifest.treeDigest !== candidateDigest) {
      throw new Error("GitHub publication commit tree does not match embedded candidate manifest");
    }
    return {
      nativeId: encodeNative({ kind: "publication", repositoryKey: repo.repositoryKey, targetRef }),
      repositoryKey: repo.repositoryKey,
      targetRef,
      targetRevision: revision,
      baseRevision: fieldString(parent as Readonly<Record<string, unknown>>, "sha"),
      candidateDigest,
      candidateManifest: metadata.candidateManifest,
      factoryRunId: metadata.factoryRunId,
      idempotencyKey: metadata.idempotencyKey,
      status: "published",
      url: `https://github.com/${repo.repositoryKey}/tree/${encodeURIComponent(branchFromRef(targetRef))}`,
    };
  }

  private async readRefSha(
    repo: GitHubRepositoryIdentity,
    ref: string,
    credentialReferenceId: CredentialReferenceId,
  ): Promise<string | undefined> {
    try {
      const payload = parseObject(
        await this.request(
          credentialReferenceId,
          "vcs.ref.read",
          "GET",
          `${this.repoUrl(repo)}/git/ref/${refApiPath(ref).split("/").map(encodeURIComponent).join("/")}`,
          undefined,
          [200],
        ),
      );
      const object = payload.object;
      if (!object || typeof object !== "object" || Array.isArray(object))
        throw new Error("GitHub ref is missing object identity");
      return fieldString(object as Readonly<Record<string, unknown>>, "sha");
    } catch (error) {
      if (error instanceof GitHubHttpError && error.status === 404) return undefined;
      throw error;
    }
  }

  private repoUrl(repo: GitHubRepositoryIdentity): string {
    return `${this.apiBase}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.repository)}`;
  }

  private async request(
    credentialReferenceId: CredentialReferenceId,
    purpose: string,
    method: string,
    url: string,
    body: unknown,
    acceptedStatuses: readonly number[],
  ): Promise<string> {
    const lease = await this.credentials.resolve(credentialReferenceId, purpose);
    try {
      const response = await this.fetcher(url, {
        method,
        headers: {
          Accept: "application/vnd.github+json",
          Authorization: `Bearer ${lease.token}`,
          "X-GitHub-Api-Version": GITHUB_API_VERSION,
          "Content-Type": "application/json",
        },
        ...(body === undefined ? {} : { body: JSON.stringify(body) }),
      });
      const text = await response.text();
      if (!acceptedStatuses.includes(response.status)) {
        throw new GitHubHttpError(response.status, safeGitHubMessage(response.status, text));
      }
      return text;
    } finally {
      lease.dispose();
    }
  }
}
