// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
// Moved mechanically from scripts/src/gh.ts:4730-5231.

import path from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety";
import { errorPayload, githubGraphqlRequest, githubRequest, isGitHubError, runnerDisposition } from "./client";
import { issueRead } from "./issue-read";
import { isRecord } from "./notify-claudeqq";
import { prState } from "./pr-commands";
import { ghText } from "./render";
import { PR_CLOSEOUT_JSON_FIELDS, UNIDESK_CLI_CONFIG_PATH } from "./types";
import type { GitHubCommandResult, GitHubErrorPayload, GitHubPullRequest, GitHubPullRequestFile, GitHubPullRequestGraphqlMetadata, GitHubPullRequestGraphqlStatusCheckRollup, GitHubPullRequestGraphqlStatusContext, PrMergeUnknownRetryConfig } from "./types";

export function prStateDetail(pr: GitHubPullRequest): "open" | "closed" | "merged" {
  if (pr.merged === true || pr.merged_at !== null && pr.merged_at !== undefined) return "merged";
  return pr.state === "closed" ? "closed" : "open";
}

export function prSummary(pr: GitHubPullRequest, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
  const stateDetail = prStateDetail(pr);
  const closedAt = pr.closed_at ?? pr.merged_at ?? null;
  const mergedAt = pr.merged_at ?? null;
  const mergeCommitSha = pr.merge_commit_sha ?? null;
  const body = pr.body ?? "";
  const includeBody = options.includeBody ?? true;
  const includePreview = options.includePreview ?? true;
  const summary: Record<string, unknown> = {
    id: pr.id,
    number: pr.number,
    title: pr.title,
    state: pr.state,
    stateDetail,
    draft: pr.draft ?? false,
    url: pr.html_url,
    author: pr.user?.login ?? null,
    head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
    base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
    headRefName: pr.head?.ref ?? null,
    baseRefName: pr.base?.ref ?? null,
    createdAt: pr.created_at ?? null,
    updatedAt: pr.updated_at ?? null,
    closed: stateDetail !== "open",
    closedAt,
    merged: stateDetail === "merged",
    mergedAt,
    mergeCommit: stateDetail === "merged" && mergeCommitSha !== null ? { oid: mergeCommitSha } : null,
    bodyChars: body.length,
    bodySha: bodySha(body),
  };
  if (includeBody) {
    summary.body = body;
  } else {
    summary.bodyOmitted = true;
    summary.fullBodyIncluded = false;
    if (includePreview) {
      summary.bodyPreview = preview(body);
      summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
    }
  }
  return summary;
}

export function numberOrNull(value: number | undefined): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

export function prCompactSummary(pr: GitHubPullRequest): Record<string, unknown> {
  return {
    id: pr.id,
    number: pr.number,
    title: pr.title,
    state: pr.state,
    draft: pr.draft ?? false,
    url: pr.html_url,
    author: pr.user?.login ?? null,
    head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
    base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
    createdAt: pr.created_at ?? null,
    updatedAt: pr.updated_at ?? null,
  };
}

export function prFileSummary(file: GitHubPullRequestFile): Record<string, unknown> {
  return {
    filename: file.filename,
    status: file.status ?? null,
    additions: numberOrNull(file.additions),
    deletions: numberOrNull(file.deletions),
    changes: numberOrNull(file.changes),
    previousFilename: file.previous_filename ?? null,
    sha: file.sha ?? null,
    blobUrl: file.blob_url ?? null,
    contentsUrl: file.contents_url ?? null,
  };
}

export function sumPrFileStats(files: GitHubPullRequestFile[]): { additions: number; deletions: number; changes: number } {
  return files.reduce((accumulator, file) => {
    accumulator.additions += file.additions ?? 0;
    accumulator.deletions += file.deletions ?? 0;
    accumulator.changes += file.changes ?? ((file.additions ?? 0) + (file.deletions ?? 0));
    return accumulator;
  }, { additions: 0, deletions: 0, changes: 0 });
}

export function selectedPrJson(summary: Record<string, unknown>, fields: readonly string[]): Record<string, unknown> {
  const selected: Record<string, unknown> = {};
  for (const field of fields) selected[field] = summary[field];
  return selected;
}

export function prMetadataSummary(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
  return {
    mergeable: metadata.mergeable ?? null,
    mergeStateStatus: metadata.mergeStateStatus ?? null,
    statusCheckRollup: metadata.statusCheckRollup ?? null,
    headRefName: metadata.headRefName ?? null,
    baseRefName: metadata.baseRefName ?? null,
  };
}

export function prMergeBoundary(): Record<string, unknown> {
  return {
    runnerAllowed: ["pr create", "pr update/edit", "pr comment", "pr read/view", "pr close", "pr merge after explicit command authorization"],
    ordinaryRunnerFinalActionAllowed: true,
    commanderRequiredWhen: ["conflicts", "failed required checks", "production/runtime/release/security/database scope", "ambiguous task boundary"],
    hostAllowedToolsAfterReview: ["bun scripts/cli.ts gh pr merge", "GitHub UI merge/close"],
    unideskCliMergeSupported: true,
  };
}

export function prCloseoutMetadata(metadata: GitHubPullRequestGraphqlMetadata): Record<string, unknown> {
  const summary = prMetadataSummary(metadata);
  const missingOrUnknownFields = PR_CLOSEOUT_JSON_FIELDS.filter((field) => {
    const value = summary[field];
    if (value === null || value === undefined) return true;
    return typeof value === "string" && value.toUpperCase() === "UNKNOWN";
  });
  return {
    ok: missingOrUnknownFields.length === 0,
    source: "github-graphql",
    missingOrUnknownFields,
    advice: missingOrUnknownFields.length === 0
      ? "Closeout GraphQL metadata is present; still review checks, branch scope, and task boundary before final action."
      : "Some closeout GraphQL metadata is missing or unknown; retry or cross-check with system gh/GitHub UI before treating the PR as merge-ready.",
    mergeBoundary: prMergeBoundary(),
  };
}

export function prCloseoutMetadataError(error: GitHubErrorPayload): Record<string, unknown> {
  return {
    ok: false,
    source: "github-graphql",
    degradedReason: error.degradedReason,
    runnerDisposition: error.runnerDisposition,
    message: error.message,
    advice: "Closeout GraphQL metadata was not available; retry or use a read-only system gh/GitHub UI cross-check before deciding merge readiness.",
    mergeBoundary: prMergeBoundary(),
  };
}

export function needsPrGraphqlMetadata(fields: readonly string[] | undefined): boolean {
  if (fields === undefined) return false;
  return fields.some((field) => field === "mergeable" || field === "mergeStateStatus" || field === "statusCheckRollup");
}

export async function prGraphqlMetadata(repo: string, token: string, number: number): Promise<GitHubPullRequestGraphqlMetadata | GitHubErrorPayload> {
  const { owner, name } = repoParts(repo);
  const query = `
    query PullRequestCloseoutMetadata($owner: String!, $name: String!, $number: Int!) {
      repository(owner: $owner, name: $name) {
        pullRequest(number: $number) {
          mergeable
          mergeStateStatus
          headRefName
          baseRefName
          statusCheckRollup {
            state
            contexts(first: 100) {
              nodes {
                __typename
                ... on CheckRun {
                  name
                  status
                  conclusion
                }
                ... on StatusContext {
                  context
                  state
                  targetUrl
                  description
                }
              }
            }
          }
        }
      }
    }
  `;
  const response = await githubGraphqlRequest<{ repository?: { pullRequest?: GitHubPullRequestGraphqlMetadata | null } }>(token, query, { owner, name, number });
  if (isGitHubError(response)) return response;
  const pullRequest = response.repository?.pullRequest;
  if (pullRequest === null || pullRequest === undefined) {
    return errorPayload("invalid-response", "GitHub GraphQL PR metadata response was missing repository.pullRequest", { details: response });
  }
  return pullRequest;
}

export function statusContextName(context: GitHubPullRequestGraphqlStatusContext): string {
  return context.name ?? context.context ?? "(unnamed status check)";
}

export function statusContextBucket(context: GitHubPullRequestGraphqlStatusContext): string {
  const type = context.__typename ?? "StatusContext";
  const status = (context.status ?? "").toUpperCase();
  const conclusion = (context.conclusion ?? "").toUpperCase();
  const state = (context.state ?? "").toUpperCase();
  if (type === "CheckRun") {
    if (status.length > 0 && status !== "COMPLETED") return "pending";
    if (conclusion === "SUCCESS") return "success";
    if (conclusion === "NEUTRAL") return "neutral";
    if (conclusion === "SKIPPED") return "skipped";
    if (conclusion === "CANCELLED") return "cancelled";
    if (conclusion === "TIMED_OUT") return "timed-out";
    if (["FAILURE", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(conclusion)) return "failure";
    return "unknown";
  }
  if (state === "SUCCESS") return "success";
  if (state === "PENDING" || state === "EXPECTED") return "pending";
  if (state === "FAILURE" || state === "ERROR") return "failure";
  return "unknown";
}

export function compactStatusContext(context: GitHubPullRequestGraphqlStatusContext): Record<string, unknown> {
  const bucket = statusContextBucket(context);
  return {
    name: statusContextName(context),
    type: context.__typename ?? "StatusContext",
    bucket,
    status: context.status ?? null,
    conclusion: context.conclusion ?? null,
    state: context.state ?? null,
    targetUrl: context.targetUrl ?? null,
    description: context.description === undefined || context.description === null ? null : preview(context.description),
  };
}

export function statusRollupSummary(
  repo: string,
  number: number,
  rollup: GitHubPullRequestGraphqlStatusCheckRollup | null | undefined,
  includeRaw: boolean,
): Record<string, unknown> {
  const contexts = rollup?.contexts?.nodes ?? [];
  const compactContexts = contexts.map(compactStatusContext);
  const counts: Record<string, number> = {
    success: 0,
    failure: 0,
    pending: 0,
    neutral: 0,
    skipped: 0,
    cancelled: 0,
    "timed-out": 0,
    unknown: 0,
  };
  for (const context of compactContexts) {
    const bucket = String(context.bucket ?? "unknown");
    counts[bucket] = (counts[bucket] ?? 0) + 1;
  }
  const nonSuccess = compactContexts.filter((context) => context.bucket !== "success");
  const failing = compactContexts.filter((context) => context.bucket === "failure" || context.bucket === "cancelled" || context.bucket === "timed-out");
  const pending = compactContexts.filter((context) => context.bucket === "pending" || context.bucket === "unknown");
  const neutral = compactContexts.filter((context) => context.bucket === "neutral" || context.bucket === "skipped");
  return {
    state: rollup?.state ?? null,
    totalContexts: compactContexts.length,
    counts,
    failingContexts: failing.slice(0, 10),
    pendingContexts: pending.slice(0, 10),
    neutralContexts: neutral.slice(0, 10),
    nonSuccessPreview: nonSuccess.slice(0, 12),
    omittedNonSuccessContexts: Math.max(0, nonSuccess.length - 12),
    rawOmitted: !includeRaw,
    ...(includeRaw
      ? { contexts: compactContexts, raw: rollup ?? null }
      : { fullHint: `bun scripts/cli.ts gh pr preflight ${number} --repo ${repo} --full` }),
  };
}

export function compactProbeOk(value: unknown): boolean | null {
  if (value === "ok") return true;
  if (value === "skipped") return null;
  if (isRecord(value) && value.ok === true) return true;
  if (isRecord(value) && value.ok === false) return false;
  return null;
}

export function compactAuthCapability(auth: GitHubCommandResult): Record<string, unknown> {
  const token = isRecord(auth.token) ? auth.token : {};
  const gh = isRecord(auth.gh) ? auth.gh : {};
  const probes = isRecord(auth.probes) ? auth.probes : {};
  return {
    ok: auth.ok === true,
    degradedReason: auth.ok === false ? auth.degradedReason ?? null : null,
    runnerDisposition: auth.ok === false ? auth.runnerDisposition ?? null : null,
    tokenPresent: token.present === true,
    tokenSource: typeof token.source === "string" ? token.source : null,
    ghFallbackAttempted: token.ghFallbackAttempted === true,
    ghBinaryFound: gh.binaryFound === true,
    restApi: compactProbeOk(probes.restApi),
    repoVisible: compactProbeOk(probes.repo),
    issueRead: compactProbeOk(probes.issueRead),
    valuesPrinted: false,
  };
}

export function prPreflightPolicy(repo: string, number: number): Record<string, unknown> {
  return {
    readOnly: true,
    writesRemote: false,
    createsPr: false,
    comments: false,
    mergesPr: false,
    mergeCommandSupported: true,
    mergeCommand: `bun scripts/cli.ts gh pr merge ${number} --repo ${repo} --merge`,
    unideskCliMergeSupported: true,
    ordinaryRunnerFinalActionAllowed: true,
    note: "This preflight only reads GitHub auth, PR metadata, mergeability, and status checks for diagnosis; gh pr merge performs its own guarded readiness check and does not require this preflight step.",
  };
}

export function preflightPullRequestSummary(summary: Record<string, unknown>): Record<string, unknown> {
  return {
    number: summary.number ?? null,
    title: summary.title ?? null,
    state: summary.state ?? null,
    stateDetail: summary.stateDetail ?? null,
    draft: summary.draft ?? null,
    url: summary.url ?? null,
    author: summary.author ?? null,
    head: summary.head ?? null,
    base: summary.base ?? null,
    headRefName: summary.headRefName ?? null,
    baseRefName: summary.baseRefName ?? null,
    closed: summary.closed ?? null,
    merged: summary.merged ?? null,
    mergedAt: summary.mergedAt ?? null,
    updatedAt: summary.updatedAt ?? null,
    bodyOmitted: true,
  };
}

export function prCloseoutSummary(
  pr: Record<string, unknown>,
  metadata: Record<string, unknown>,
  statusChecks: Record<string, unknown>,
): Record<string, unknown> {
  const blockers: string[] = [];
  const pending: string[] = [];
  const prState = typeof pr.state === "string" ? pr.state.toLowerCase() : "";
  if (prState !== "open") blockers.push(`state:${pr.state ?? "unknown"}`);
  if (pr.draft === true) blockers.push("draft:true");
  const mergeable = typeof metadata.mergeable === "string" ? metadata.mergeable.toUpperCase() : null;
  if (mergeable === "MERGEABLE") {
    // Ready signal, combined with mergeStateStatus and checks below.
  } else if (mergeable === "CONFLICTING" || mergeable === "NOT_MERGEABLE") {
    blockers.push(`mergeable:${mergeable}`);
  } else {
    pending.push(`mergeable:${mergeable ?? "unknown"}`);
  }
  const mergeStateStatus = typeof metadata.mergeStateStatus === "string" ? metadata.mergeStateStatus.toUpperCase() : null;
  if (mergeStateStatus === "CLEAN") {
    // Ready signal.
  } else if (mergeStateStatus === "UNKNOWN" || mergeStateStatus === null) {
    pending.push(`mergeStateStatus:${mergeStateStatus ?? "unknown"}`);
  } else {
    blockers.push(`mergeStateStatus:${mergeStateStatus}`);
  }
  const rollupState = typeof statusChecks.state === "string" ? statusChecks.state.toUpperCase() : null;
  const totalContexts = typeof statusChecks.totalContexts === "number" ? statusChecks.totalContexts : 0;
  const counts = isRecord(statusChecks.counts) ? statusChecks.counts : {};
  const failureCount = Number(counts.failure ?? 0) + Number(counts.cancelled ?? 0) + Number(counts["timed-out"] ?? 0);
  const pendingCount = Number(counts.pending ?? 0) + Number(counts.unknown ?? 0);
  if (failureCount > 0) blockers.push(`statusChecks:failure:${failureCount}`);
  if (pendingCount > 0) pending.push(`statusChecks:pending:${pendingCount}`);
  if (rollupState === "SUCCESS" || rollupState === null && totalContexts === 0) {
    // Ready signal.
  } else if (rollupState === "PENDING" || rollupState === "EXPECTED" || rollupState === null) {
    pending.push(`statusCheckRollup:${rollupState ?? "unknown"}`);
  } else if (rollupState !== null) {
    blockers.push(`statusCheckRollup:${rollupState}`);
  }
  const readyForCommanderMerge = blockers.length === 0 && pending.length === 0;
  return {
    readyForCommanderMerge,
    conclusion: readyForCommanderMerge ? "ready" : blockers.length > 0 ? "blocked" : "pending",
    blockers,
    pending,
    commanderAction: readyForCommanderMerge
      ? "review and merge through bun scripts/cli.ts gh pr merge when task boundaries allow"
      : "resolve blockers or rerun after GitHub finishes computing mergeability/status checks",
  };
}

export function stripYamlComment(value: string): string {
  const index = value.indexOf("#");
  return (index >= 0 ? value.slice(0, index) : value).trim();
}

export function yamlScalarByPath(text: string, keyPath: string[]): string | null {
  const stack: Array<{ indent: number; key: string }> = [];
  for (const line of text.split(/\r?\n/u)) {
    if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue;
    const match = /^(\s*)([A-Za-z0-9_-]+):(?:\s*(.*))?$/u.exec(line);
    if (match === null) continue;
    const indent = match[1].length;
    while (stack.length > 0 && stack[stack.length - 1].indent >= indent) stack.pop();
    const key = match[2];
    const rawValue = stripYamlComment(match[3] ?? "");
    const currentPath = [...stack.map((item) => item.key), key];
    if (currentPath.length === keyPath.length && currentPath.every((item, index) => item === keyPath[index])) {
      return rawValue.length === 0 ? null : rawValue;
    }
    stack.push({ indent, key });
  }
  return null;
}

export function yamlNumberByPath(text: string, keyPath: string[], configPath: string): number {
  const value = yamlScalarByPath(text, keyPath);
  if (value === null) throw new Error(`${configPath} missing numeric config ${keyPath.join(".")}`);
  const parsed = Number(value);
  if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${configPath} config ${keyPath.join(".")} must be a positive number`);
  return parsed;
}

export function loadPrMergeUnknownRetryConfig(): PrMergeUnknownRetryConfig {
  const configPath = path.resolve(process.cwd(), UNIDESK_CLI_CONFIG_PATH);
  if (!existsSync(configPath)) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} is required for gh pr merge retry policy`);
  const text = readFileSync(configPath, "utf8");
  const prefix = ["github", "prMerge", "unknownRetry"];
  const config = {
    maxAttempts: Math.trunc(yamlNumberByPath(text, [...prefix, "maxAttempts"], UNIDESK_CLI_CONFIG_PATH)),
    initialDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "initialDelayMs"], UNIDESK_CLI_CONFIG_PATH)),
    maxDelayMs: Math.trunc(yamlNumberByPath(text, [...prefix, "maxDelayMs"], UNIDESK_CLI_CONFIG_PATH)),
    factor: yamlNumberByPath(text, [...prefix, "factor"], UNIDESK_CLI_CONFIG_PATH),
  };
  if (config.maxAttempts < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxAttempts must be >= 1`);
  if (config.maxDelayMs < config.initialDelayMs) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.maxDelayMs must be >= initialDelayMs`);
  if (config.factor < 1) throw new Error(`${UNIDESK_CLI_CONFIG_PATH} github.prMerge.unknownRetry.factor must be >= 1`);
  return config;
}

export function sleepMs(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export function mergeabilityHasUnknownPending(mergeability: Record<string, unknown>): boolean {
  const blockers = Array.isArray(mergeability.blockers) ? mergeability.blockers.map(String) : [];
  if (blockers.length > 0) return false;
  const pending = Array.isArray(mergeability.pending) ? mergeability.pending.map((item) => item.toLowerCase()) : [];
  return pending.some((item) => item.includes("unknown"));
}

export function nextPrMergeRetryDelayMs(config: PrMergeUnknownRetryConfig, previousDelayMs: number): number {
  return Math.min(config.maxDelayMs, Math.max(1, Math.ceil(previousDelayMs * config.factor)));
}

export function prMergeMethodFlag(method: unknown): string {
  if (method === "squash") return "--squash";
  if (method === "rebase") return "--rebase";
  return "--merge";
}

export function prMergeRetryCommand(repo: string, number: unknown, method: unknown, deleteBranch: unknown): string {
  return [
    "bun scripts/cli.ts gh pr merge",
    ghText(number),
    "--repo",
    repo,
    prMergeMethodFlag(method),
    deleteBranch === true ? "--delete-branch" : "",
  ].filter((item) => item.length > 0).join(" ");
}

export async function deleteHeadBranchAfterMerge(repo: string, token: string, pr: GitHubPullRequest): Promise<Record<string, unknown>> {
  const { owner, name } = repoParts(repo);
  const headRepo = pr.head?.repo?.full_name ?? null;
  const headRef = pr.head?.ref ?? null;
  if (headRepo !== repo || headRef === null || headRef.length === 0) {
    return {
      attempted: false,
      skippedReason: "head-repo-differs-or-ref-missing",
      headRepo,
      headRef,
    };
  }
  const encodedRef = encodeURIComponent(`heads/${headRef}`);
  const deleted = await githubRequest<unknown>(token, "DELETE", `/repos/${owner}/${name}/git/refs/${encodedRef}`);
  if (isGitHubError(deleted)) {
    return {
      attempted: true,
      ok: false,
      headRepo,
      headRef,
      error: deleted,
    };
  }
  return {
    attempted: true,
    ok: true,
    headRepo,
    headRef,
  };
}
