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

import path from "node:path";
import { mkdirSync, writeFileSync } from "node:fs";
import { binarySha, repoParts } from "./auth-and-safety";
import { classifyGitHubFetchFailure, commandError, errorPayload, githubGraphqlRequest, isGitHubError, validationError } from "./client";
import { getIssue, listIssueComments } from "./issue-read";
import { issueSummary } from "./issue-summary";
import { isGitHubCommandResult } from "./refs";
import { MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES, REQUEST_TIMEOUT_MS, USER_AGENT } from "./types";
import type { GitHubAttachmentDownload, GitHubCommandFailure, GitHubCommandResult, GitHubComment, GitHubDegradedReason, GitHubErrorPayload, GitHubIssue, GitHubIssueAttachment, GitHubOptions } from "./types";

export const GITHUB_USER_ATTACHMENT_URL_PATTERN = /https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s<>"')]+)?/giu;

export function normalizedAttachmentUrl(raw: string): string | null {
  try {
    const url = new URL(raw);
    if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith("/user-attachments/assets/")) return null;
    return url.toString();
  } catch {
    return null;
  }
}

export function redactedUrl(raw: string): string {
  try {
    const url = new URL(raw);
    url.search = "";
    url.hash = "";
    return url.toString();
  } catch {
    return raw.split("?")[0] ?? raw;
  }
}

export function shouldSendGitHubWebAuth(raw: string): boolean {
  try {
    return new URL(raw).hostname === "github.com";
  } catch {
    return false;
  }
}

export function decodeHtmlUrl(raw: string): string {
  return raw
    .replace(/&amp;/giu, "&")
    .replace(/&#x2F;/giu, "/")
    .replace(/&#x3D;/giu, "=");
}

export function attachmentAssetId(url: string): string | null {
  try {
    const parsed = new URL(url);
    return parsed.pathname.split("/").filter(Boolean).at(-1) ?? null;
  } catch {
    return null;
  }
}

export function htmlAttribute(tag: string, name: string): string | null {
  const match = new RegExp(`\\b${name}\\s*=\\s*(\"([^\"]*)\"|'([^']*)'|([^\\s>]+))`, "iu").exec(tag);
  return match?.[2] ?? match?.[3] ?? match?.[4] ?? null;
}

export function extractIssueAttachmentsFromBody(body: string | null | undefined, source: Omit<GitHubIssueAttachment, "index" | "url" | "assetId" | "marker">): Array<Omit<GitHubIssueAttachment, "index">> {
  const text = body ?? "";
  const attachments: Array<Omit<GitHubIssueAttachment, "index">> = [];
  const seen = new Set<string>();
  const add = (rawUrl: string, marker: GitHubIssueAttachment["marker"], details: Partial<GitHubIssueAttachment> = {}) => {
    const url = normalizedAttachmentUrl(rawUrl);
    if (url === null || seen.has(url)) return;
    seen.add(url);
    attachments.push({
      ...source,
      marker,
      url,
      assetId: attachmentAssetId(url),
      alt: details.alt ?? null,
      width: details.width ?? null,
      height: details.height ?? null,
    });
  };

  for (const match of text.matchAll(/!\[([^\]]*)\]\((https:\/\/github\.com\/user-attachments\/assets\/[A-Za-z0-9._-]+(?:\?[^\s)"']+)?)\)/giu)) {
    add(match[2] ?? "", "markdown-image", { alt: match[1] ?? null });
  }
  for (const match of text.matchAll(/<img\b[^>]*>/giu)) {
    const tag = match[0] ?? "";
    const src = htmlAttribute(tag, "src");
    if (src === null) continue;
    add(src, "html-img", {
      alt: htmlAttribute(tag, "alt"),
      width: htmlAttribute(tag, "width"),
      height: htmlAttribute(tag, "height"),
    });
  }
  for (const match of text.matchAll(GITHUB_USER_ATTACHMENT_URL_PATTERN)) add(match[0] ?? "", "plain-url");
  return attachments;
}

export async function collectIssueAttachments(repo: string, token: string, issueNumber: number, commandName: string): Promise<{ issue: GitHubIssue; comments: GitHubComment[]; attachments: GitHubIssueAttachment[] } | GitHubCommandFailure> {
  const issue = await getIssue(token, repo, issueNumber);
  if (isGitHubError(issue)) return commandError(commandName, repo, issue, { issueNumber });
  const comments = await listIssueComments(token, repo, issueNumber);
  if (isGitHubError(comments)) return commandError(commandName, repo, comments, { issueNumber, issue: issueSummary(issue) });
  const dedupe = new Set<string>();
  const attachments: GitHubIssueAttachment[] = [];
  const append = (items: Array<Omit<GitHubIssueAttachment, "index">>) => {
    for (const item of items) {
      if (dedupe.has(item.url)) continue;
      dedupe.add(item.url);
      attachments.push({ ...item, index: attachments.length + 1 });
    }
  };
  append(extractIssueAttachmentsFromBody(issue.body, {
    source: "issue-body",
    sourceUrl: issue.html_url,
    author: issue.user?.login ?? null,
    createdAt: issue.created_at ?? null,
    updatedAt: issue.updated_at ?? null,
  }));
  for (const comment of comments) {
    append(extractIssueAttachmentsFromBody(comment.body, {
      source: "comment",
      sourceUrl: comment.html_url,
      commentId: comment.id,
      author: comment.user?.login ?? null,
      createdAt: comment.created_at ?? null,
      updatedAt: comment.updated_at ?? null,
    }));
  }
  return { issue, comments, attachments };
}

export function issueAttachmentSummary(attachment: GitHubIssueAttachment): Record<string, unknown> {
  return {
    index: attachment.index,
    url: attachment.url,
    assetId: attachment.assetId,
    source: attachment.source,
    sourceUrl: attachment.sourceUrl,
    commentId: attachment.commentId ?? null,
    marker: attachment.marker,
    alt: attachment.alt ?? null,
    width: attachment.width ?? null,
    height: attachment.height ?? null,
    author: attachment.author ?? null,
    createdAt: attachment.createdAt ?? null,
    updatedAt: attachment.updatedAt ?? null,
  };
}

export async function issueAttachmentList(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> {
  const commandName = "issue attachment list";
  const collected = await collectIssueAttachments(repo, token, issueNumber, commandName);
  if (isGitHubCommandResult(collected)) return collected;
  return {
    ok: true,
    command: commandName,
    repo,
    readOnly: true,
    issue: issueSummary(collected.issue),
    commentsScanned: collected.comments.length,
    count: collected.attachments.length,
    attachments: collected.attachments.map(issueAttachmentSummary),
    next: collected.attachments.length > 0
      ? {
        command: `bun scripts/cli.ts gh issue attachment download ${issueNumber} --repo ${repo} --attachment 1 --output /tmp/gh-issue-${issueNumber}-attachment-1`,
      }
      : null,
    request: {
      method: "GET",
      paths: [
        `/repos/{owner}/{repo}/issues/${issueNumber}`,
        `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
      ],
    },
    rest: true,
  };
}

export function selectIssueAttachment(attachments: GitHubIssueAttachment[], selector: string | undefined): GitHubIssueAttachment | null {
  if (attachments.length === 0) return null;
  if (selector === undefined) return attachments[0] ?? null;
  const trimmed = selector.trim();
  if (/^\d+$/u.test(trimmed)) {
    const index = Number(trimmed);
    return attachments.find((attachment) => attachment.index === index) ?? null;
  }
  const url = normalizedAttachmentUrl(trimmed);
  if (url !== null) return attachments.find((attachment) => attachment.url === url) ?? null;
  return attachments.find((attachment) => attachment.assetId === trimmed) ?? null;
}

export function attachmentExtension(contentType: string | null): string {
  const normalized = (contentType ?? "").split(";")[0]?.trim().toLowerCase();
  if (normalized === "image/png") return ".png";
  if (normalized === "image/jpeg" || normalized === "image/jpg") return ".jpg";
  if (normalized === "image/gif") return ".gif";
  if (normalized === "image/webp") return ".webp";
  if (normalized === "image/svg+xml") return ".svg";
  return ".bin";
}

export function defaultAttachmentOutputPath(repo: string, issueNumber: number, attachment: GitHubIssueAttachment, contentType: string | null): string {
  const safeRepo = repo.replace(/[^A-Za-z0-9._-]+/gu, "_");
  return path.join("/tmp/unidesk-gh-attachments", `${safeRepo}-issue-${issueNumber}-attachment-${attachment.index}${attachmentExtension(contentType)}`);
}

export function attachmentFetchError(url: string, status: number, message: string, details: Record<string, unknown> = {}): GitHubErrorPayload {
  const reason: GitHubDegradedReason = status === 401
    ? "auth-failed"
    : status === 403
      ? "permission-denied"
      : status === 404
        ? "validation-failed"
        : "invalid-response";
  return errorPayload(reason, message, {
    status,
    details: {
      url: redactedUrl(url),
      ...details,
    },
    request: { method: "GET", path: redactedUrl(url) },
  });
}

export async function fetchAttachmentResponse(url: string, token: string, useAuth: boolean, redirect: RequestRedirect): Promise<Response | GitHubErrorPayload> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
  try {
    const headers: Record<string, string> = {
      Accept: "image/*,application/octet-stream,*/*",
      "User-Agent": USER_AGENT,
    };
    if (useAuth && token.length > 0 && shouldSendGitHubWebAuth(url)) headers.Authorization = `Bearer ${token}`;
    return await fetch(url, {
      method: "GET",
      redirect,
      signal: controller.signal,
      headers,
    });
  } catch (error) {
    const classified = classifyGitHubFetchFailure(error);
    return errorPayload(classified.reason, classified.message, {
      request: { method: "GET", path: redactedUrl(url) },
      details: { ...classified.details, url: redactedUrl(url) },
      retryable: classified.retryable,
      commanderAction: classified.commanderAction,
    });
  } finally {
    clearTimeout(timeout);
  }
}

export async function readAttachmentResponse(response: Response, url: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> {
  if (!response.ok) return attachmentFetchError(url, response.status, response.statusText || "GitHub attachment download failed");
  const contentLengthHeader = response.headers.get("content-length");
  const contentLength = contentLengthHeader === null ? null : Number(contentLengthHeader);
  if (contentLength !== null && Number.isFinite(contentLength) && contentLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) {
    return attachmentFetchError(url, 413, "GitHub attachment is larger than the UniDesk gh download safety limit", {
      contentLength,
      maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES,
    });
  }
  let buffer: Buffer;
  try {
    buffer = Buffer.from(await response.arrayBuffer());
  } catch (error) {
    const classified = classifyGitHubFetchFailure(error);
    return errorPayload(classified.reason, classified.message, {
      request: { method: "GET", path: redactedUrl(url) },
      details: { ...classified.details, url: redactedUrl(url), phase: "read-body" },
      retryable: classified.retryable,
      commanderAction: classified.commanderAction,
    });
  }
  if (buffer.byteLength > MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES) {
    return attachmentFetchError(url, 413, "GitHub attachment body exceeded the UniDesk gh download safety limit", {
      bytes: buffer.byteLength,
      maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES,
    });
  }
  return {
    buffer,
    contentType: response.headers.get("content-type"),
    contentLengthHeader,
    finalUrl: response.url,
  };
}

export async function downloadGitHubAttachment(url: string, token: string): Promise<GitHubAttachmentDownload | GitHubErrorPayload> {
  const direct = await fetchAttachmentResponse(url, "", false, "follow");
  if (!isGitHubError(direct)) {
    if (direct.ok) return readAttachmentResponse(direct, url);
  } else {
    return direct;
  }

  const authenticated = await fetchAttachmentResponse(url, token, true, "manual");
  if (isGitHubError(authenticated)) return authenticated;
  if (authenticated.status >= 300 && authenticated.status < 400) {
    const location = authenticated.headers.get("location");
    if (location === null || location.length === 0) return attachmentFetchError(url, authenticated.status, "GitHub attachment redirect did not include a Location header");
    const redirectedUrl = new URL(location, url).toString();
    const redirected = await fetchAttachmentResponse(redirectedUrl, "", false, "follow");
    if (isGitHubError(redirected)) return redirected;
    return readAttachmentResponse(redirected, redirectedUrl);
  }
  return readAttachmentResponse(authenticated, url);
}

export function extractRenderedAttachmentUrlCandidates(html: string | null | undefined, assetId: string | null, source: Record<string, unknown>): Array<{ url: string; source: Record<string, unknown> }> {
  if (assetId === null || assetId.length === 0) return [];
  const text = html ?? "";
  const candidates: Array<{ url: string; source: Record<string, unknown> }> = [];
  const seen = new Set<string>();
  for (const match of text.matchAll(/https?:\/\/[^"'<> ]+/giu)) {
    const decoded = decodeHtmlUrl(match[0] ?? "");
    if (!decoded.includes(assetId)) continue;
    if (!/private-user-images\.githubusercontent\.com|github-production-user-asset|github\.com\/user-attachments\/assets/iu.test(decoded)) continue;
    if (seen.has(decoded)) continue;
    seen.add(decoded);
    candidates.push({ url: decoded, source });
  }
  return candidates;
}

export async function resolveRenderedIssueAttachmentUrl(repo: string, token: string, issueNumber: number, attachment: GitHubIssueAttachment): Promise<{ url: string; source: Record<string, unknown> } | GitHubErrorPayload | null> {
  const { owner, name } = repoParts(repo);
  const query = `
    query UniDeskIssueAttachmentRenderedUrl($owner: String!, $name: String!, $number: Int!) {
      repository(owner: $owner, name: $name) {
        issue(number: $number) {
          bodyHTML
          comments(first: 100) {
            nodes {
              id
              url
              bodyHTML
            }
          }
        }
      }
    }
  `;
  const response = await githubGraphqlRequest<{
    repository?: {
      issue?: {
        bodyHTML?: string | null;
        comments?: {
          nodes?: Array<{ id?: string; url?: string; bodyHTML?: string | null } | null> | null;
        } | null;
      } | null;
    } | null;
  }>(token, query, { owner, name, number: issueNumber });
  if (isGitHubError(response)) return response;
  const issue = response.repository?.issue;
  if (issue === null || issue === undefined) return null;
  const candidates = [
    ...extractRenderedAttachmentUrlCandidates(issue.bodyHTML, attachment.assetId, { source: "issue-bodyHTML" }),
    ...((issue.comments?.nodes ?? []).flatMap((comment) => extractRenderedAttachmentUrlCandidates(comment?.bodyHTML, attachment.assetId, {
      source: "comment-bodyHTML",
      commentNodeId: comment?.id ?? null,
      commentUrl: comment?.url ?? null,
    }))),
  ];
  return candidates[0] ?? null;
}

export async function issueAttachmentDownload(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
  const commandName = "issue attachment download";
  const collected = await collectIssueAttachments(repo, token, issueNumber, commandName);
  if (isGitHubCommandResult(collected)) return collected;
  const attachment = selectIssueAttachment(collected.attachments, options.attachmentSelector);
  if (attachment === null) {
    return validationError(commandName, repo, "No matching GitHub user attachment found in the issue body or comments", {
      issueNumber,
      selector: options.attachmentSelector ?? null,
      attachments: collected.attachments.map(issueAttachmentSummary),
    });
  }
  if (options.dryRun) {
    return {
      ok: true,
      command: commandName,
      repo,
      dryRun: true,
      planned: true,
      issue: issueSummary(collected.issue),
      attachment: issueAttachmentSummary(attachment),
      outputPath: options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, null),
      noWrite: true,
    };
  }
  let downloaded = await downloadGitHubAttachment(attachment.url, token);
  let renderedFallback: Record<string, unknown> | null = null;
  if (isGitHubError(downloaded)) {
    const rendered = await resolveRenderedIssueAttachmentUrl(repo, token, issueNumber, attachment);
    if (isGitHubError(rendered)) {
      return commandError(commandName, repo, downloaded, {
        issueNumber,
        attachment: issueAttachmentSummary(attachment),
        renderedFallback: {
          attempted: true,
          ok: false,
          degradedReason: rendered.degradedReason,
          message: rendered.message,
        },
      });
    }
    if (rendered !== null) {
      renderedFallback = {
        attempted: true,
        ok: true,
        source: rendered.source,
        url: redactedUrl(rendered.url),
      };
      downloaded = await downloadGitHubAttachment(rendered.url, token);
    } else {
      renderedFallback = {
        attempted: true,
        ok: false,
        reason: "rendered-bodyHTML-url-not-found",
      };
    }
  }
  if (isGitHubError(downloaded)) return commandError(commandName, repo, downloaded, { issueNumber, attachment: issueAttachmentSummary(attachment), renderedFallback });
  const outputPath = options.outputPath ?? defaultAttachmentOutputPath(repo, issueNumber, attachment, downloaded.contentType);
  mkdirSync(path.dirname(outputPath), { recursive: true });
  writeFileSync(outputPath, downloaded.buffer);
  return {
    ok: true,
    command: commandName,
    repo,
    issue: issueSummary(collected.issue),
    attachment: issueAttachmentSummary(attachment),
    output: {
      path: outputPath,
      bytes: downloaded.buffer.byteLength,
      sha256: binarySha(downloaded.buffer),
      contentType: downloaded.contentType,
      contentLengthHeader: downloaded.contentLengthHeader,
      finalUrl: redactedUrl(downloaded.finalUrl),
    },
    request: {
      method: "GET",
      url: attachment.url,
      maxBytes: MAX_ISSUE_ATTACHMENT_DOWNLOAD_BYTES,
    },
    renderedFallback,
    valuesPrinted: false,
  };
}
