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

import path from "node:path";
import { bodySafetySignals, preview, previewLines } from "./auth-and-safety";
import { isRecord } from "./notify-claudeqq";
import { GITHUB_API, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types";
import type { GitHubCommandResult, GitHubDegradedReason, GitHubErrorPayload, GitHubOptions, GitHubTokenProbe, RunnerDisposition } from "./types";

export function writeBodyPlan(command: "issue create" | "issue comment create" | "issue comment update" | "pr create" | "pr comment" | "pr comment update", repo: string, body: string, bodySource: Record<string, unknown>, extra: Record<string, unknown> = {}): Record<string, unknown> {
  const isIssueWrite = command === "issue create" || command === "issue comment create" || command === "issue comment update";
  const isCommentUpdate = command === "issue comment update" || command === "pr comment update";
  const source = String(bodySource.kind ?? "unknown");
  const requestBody: Record<string, unknown> = {
    bodyChars: body.length,
    bodySource,
  };
  if (command === "issue create" && typeof extra.title === "string") requestBody.title = extra.title;
  if (command === "issue create" && Array.isArray(extra.labels)) requestBody.labels = extra.labels;
  return {
    repo,
    bodySource,
    source,
    bodyPreview: preview(body),
    bodyPreviewLines: previewLines(body),
    ...bodySafetySignals(body),
    request: {
      method: isCommentUpdate ? "PATCH" : "POST",
      path: isIssueWrite
        ? (command === "issue create"
          ? "/repos/{owner}/{repo}/issues"
          : isCommentUpdate
            ? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
            : "/repos/{owner}/{repo}/issues/{issue_number}/comments")
        : (command === "pr create"
          ? "/repos/{owner}/{repo}/pulls"
          : isCommentUpdate
            ? "/repos/{owner}/{repo}/issues/comments/{comment_id}"
            : "/repos/{owner}/{repo}/issues/{issue_number}/comments"),
      body: requestBody,
    },
    validation: {
      source,
      rawText: source === "body-file"
        ? "read from file bytes without shell interpolation"
        : source === "stdin"
          ? "read from stdin bytes"
          : "short inline argument accepted only by command-specific safety rules",
    },
    ...extra,
  };
}

export function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition {
  if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed";
  return "infra-blocked";
}

export function sanitizedErrorDetails(parsed: unknown): unknown {
  if (typeof parsed !== "object" || parsed === null) return parsed;
  const source = parsed as Record<string, unknown>;
  const details: Record<string, unknown> = {};
  if ("message" in source) details.message = source.message;
  if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
  if ("status" in source) details.status = source.status;
  if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
  if ("raw" in source) details.raw = source.raw;
  return details;
}

export function errorProperty(value: unknown, key: string): string | null {
  if (!isRecord(value)) return null;
  const item = value[key];
  return typeof item === "string" && item.length > 0 ? item : null;
}

export function errorChainText(error: unknown): string {
  const parts: string[] = [];
  let current: unknown = error;
  const seen = new Set<unknown>();
  while (current !== null && current !== undefined && !seen.has(current)) {
    seen.add(current);
    if (current instanceof Error) {
      parts.push(current.name, current.message);
      current = (current as Error & { cause?: unknown }).cause;
      continue;
    }
    if (isRecord(current)) {
      for (const key of ["name", "message", "code", "errno", "syscall", "hostname", "host"]) {
        const value = errorProperty(current, key);
        if (value !== null) parts.push(value);
      }
      current = current.cause;
      continue;
    }
    parts.push(String(current));
    break;
  }
  return parts.join(" ").replace(/\s+/gu, " ").trim();
}

export function firstErrorProperty(error: unknown, key: string): string | null {
  let current: unknown = error;
  const seen = new Set<unknown>();
  while (current !== null && current !== undefined && !seen.has(current)) {
    seen.add(current);
    const value = errorProperty(current, key);
    if (value !== null) return value;
    current = isRecord(current) ? current.cause : current instanceof Error ? (current as Error & { cause?: unknown }).cause : null;
  }
  return null;
}

export function classifyGitHubFetchFailure(error: unknown): {
  reason: GitHubDegradedReason;
  message: string;
  retryable?: boolean;
  commanderAction?: string;
  details: Record<string, unknown>;
} {
  const text = errorChainText(error);
  const lower = text.toLowerCase();
  const isProxyFailure = /\bproxy\b/u.test(lower) && /could not resolve|connect|tunnel|proxy/i.test(text);
  const isGitHubTransient = !isProxyFailure && (
    /temporary failure in name resolution/iu.test(text)
    || /\b(eai_again|enotfound|etimedout|econnreset|econnrefused|ehostunreach|enetunreach)\b/iu.test(text)
    || /getaddrinfo|could not resolve host|name or service not known|failed to connect|connection timed out|connection reset|connection closed|other side closed|network is unreachable|socket connection/iu.test(text)
  );
  if (!isGitHubTransient) {
    return {
      reason: "network-proxy-failed",
      message: text.length > 0 ? text : "GitHub request failed before receiving an HTTP response",
      details: {
        scope: "github-api",
        transient: false,
        errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
        errorCode: firstErrorProperty(error, "code"),
        host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
      },
    };
  }
  return {
    reason: "github-transient",
    message: text.length > 0 ? text : "GitHub DNS/API connection failed before receiving an HTTP response",
    retryable: true,
    commanderAction: "retry-backoff-or-keep-running-if-heartbeat-fresh",
    details: {
      scope: "github-api",
      transient: true,
      retryable: true,
      authFailure: false,
      semanticFailure: false,
      errorName: error instanceof Error ? error.name : firstErrorProperty(error, "name"),
      errorCode: firstErrorProperty(error, "code"),
      host: firstErrorProperty(error, "hostname") ?? firstErrorProperty(error, "host"),
      commanderAction: "retry/backoff; if the Code Queue task heartbeat or trace is fresh, keep supervising instead of closing or requeueing business work",
      valuesPrinted: false,
    },
  };
}

export function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
  return {
    ok: false,
    degradedReason: reason,
    runnerDisposition: runnerDisposition(reason),
    message,
    ...extra,
  };
}

export function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
  return {
    ok: false,
    command,
    repo,
    degradedReason: error.degradedReason,
    runnerDisposition: error.runnerDisposition,
    details: error,
    ...extra,
  };
}

export function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
  const error = errorPayload("validation-failed", message, { details: extra });
  return commandError(command, repo, error, extra);
}

export function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
  const error = errorPayload("unsupported-command", message, { details: extra });
  return commandError(command, repo, error, { message, ...extra });
}

export async function parseGitHubResponse(response: Response): Promise<unknown> {
  const text = await response.text();
  if (text.length === 0) return null;
  try {
    return JSON.parse(text) as unknown;
  } catch {
    return { raw: preview(text) };
  }
}

export function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
  const lower = message.toLowerCase();
  const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
  if (status === 401) return "auth-failed";
  if (status === 403) {
    if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
    if (path.startsWith("/repos/")) return "repo-forbidden";
    return "permission-denied";
  }
  if (status === 404) {
    if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
    if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
    if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
    return "repo-not-found";
  }
  if (status === 422) return "validation-failed";
  return "invalid-response";
}

export async function githubRequest<T>(
  token: string,
  method: string,
  path: string,
  body?: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
  let response: Response;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    response = await fetch(`${GITHUB_API}${path}`, {
      method,
      signal: controller.signal,
      headers: {
        Accept: "application/vnd.github+json",
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        "User-Agent": USER_AGENT,
        "X-GitHub-Api-Version": "2022-11-28",
      },
      body: body === undefined ? undefined : JSON.stringify(body),
    });
  } catch (error) {
    const classified = classifyGitHubFetchFailure(error);
    return errorPayload(classified.reason, classified.message, {
      request: { method, path },
      details: classified.details,
      retryable: classified.retryable,
      commanderAction: classified.commanderAction,
    });
  } finally {
    clearTimeout(timeout);
  }
  const parsed = await parseGitHubResponse(response);
  if (!response.ok) {
    const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
    return errorPayload(classifyHttpStatus(response.status, message, path, response), message, {
      status: response.status,
      details: sanitizedErrorDetails(parsed),
      scopes: {
        accepted: response.headers.get("x-accepted-oauth-scopes"),
        token: response.headers.get("x-oauth-scopes"),
      },
      request: { method, path },
    });
  }
  return parsed as T;
}

export async function githubGraphqlRequest<T>(
  token: string,
  query: string,
  variables: Record<string, unknown>,
): Promise<T | GitHubErrorPayload> {
  let response: Response;
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    response = await fetch(`${GITHUB_API}/graphql`, {
      method: "POST",
      signal: controller.signal,
      headers: {
        Accept: "application/vnd.github+json",
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        "User-Agent": USER_AGENT,
        "X-GitHub-Api-Version": "2022-11-28",
      },
      body: JSON.stringify({ query, variables }),
    });
  } catch (error) {
    const classified = classifyGitHubFetchFailure(error);
    return errorPayload(classified.reason, classified.message, {
      request: { method: "POST", path: "/graphql" },
      details: classified.details,
      retryable: classified.retryable,
      commanderAction: classified.commanderAction,
    });
  } finally {
    clearTimeout(timeout);
  }
  const parsed = await parseGitHubResponse(response);
  if (!response.ok) {
    const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
    return errorPayload(classifyHttpStatus(response.status, message, "/graphql", response), message, {
      status: response.status,
      details: sanitizedErrorDetails(parsed),
      scopes: {
        accepted: response.headers.get("x-accepted-oauth-scopes"),
        token: response.headers.get("x-oauth-scopes"),
      },
      request: { method: "POST", path: "/graphql" },
    });
  }
  if (!isRecord(parsed)) {
    return errorPayload("invalid-response", "GitHub GraphQL response was not an object", { details: parsed });
  }
  if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
    const firstError = parsed.errors[0];
    const message = isRecord(firstError) && typeof firstError.message === "string" ? firstError.message : "GitHub GraphQL query failed";
    return errorPayload("validation-failed", message, {
      details: sanitizedErrorDetails(parsed),
      request: { method: "POST", path: "/graphql" },
    });
  }
  const data = parsed.data;
  if (!isRecord(data)) {
    return errorPayload("invalid-response", "GitHub GraphQL response did not include an object data payload", { details: sanitizedErrorDetails(parsed) });
  }
  return data as T;
}

export function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
  if (!tokenProbe.present) {
    if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
      return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
    }
    if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
      return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
    }
    return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
      degraded: ["missing-token"],
      token: tokenProbe,
    });
  }
  return null;
}

export function isGitHubError(value: unknown): value is GitHubErrorPayload {
  return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
}

export function issueBodyReadCommands(repo: string, issueNumber: number): Record<string, string> {
  return {
    body: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json body`,
    full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
    raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
  };
}

export function issueCommentReadCommands(repo: string, issueNumber: number): Record<string, string> {
  return {
    comments: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --json comments`,
    comment: `bun scripts/cli.ts gh issue comment view <commentId> --repo ${repo} --full`,
    full: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --full`,
    raw: `bun scripts/cli.ts gh issue view ${issueNumber} --repo ${repo} --raw`,
  };
}

export function issueWriteDisclosure(options: GitHubOptions, repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
  const explicitFullDisclosure = options.raw || options.full;
  return {
    defaultCompact: dryRun || !explicitFullDisclosure,
    explicitFullDisclosure,
    fullBodyIncluded: explicitFullDisclosure && !dryRun,
    bodyOmitted: dryRun || !explicitFullDisclosure,
    dryRunBoundedPreview: dryRun,
    note: explicitFullDisclosure && !dryRun
      ? "The returned issue object includes the full body because --full or --raw was explicitly requested."
      : "Default issue write output omits full issue.body; use readCommands.full/raw or gh issue view --json body when full text is needed.",
    readCommands: issueBodyReadCommands(repo, issueNumber),
  };
}
