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

import { preview } from "./auth-and-safety";
import { commandError, isGitHubError, runnerDisposition } from "./client";
import { issueListPaginationSummary, listIssueComments, listIssues } from "./issue-read";
import { ISSUE_SCAN_MAX_FINDINGS, MIN_SAFE_BODY_SCAN_CHARS } from "./types";
import type { EscapeBodyKind, EscapeCleanupSuggestion, EscapeFindingClassification, EscapeFindingSeverity, EscapeMatchFinding, EscapePatternDefinition, EscapeScanEntry, GitHubCommandResult } from "./types";

export function escapeSnippet(text: string, index: number, radius = 80): string {
  const start = Math.max(0, index - radius);
  const end = Math.min(text.length, index + radius + 40);
  return text.slice(start, end).replace(/\n/g, "\\n");
}

export function lineColumnAt(text: string, index: number): { lineNumber: number; column: number } {
  let lineNumber = 1;
  let column = 1;
  for (let cursor = 0; cursor < index && cursor < text.length; cursor += 1) {
    if (text[cursor] === "\n") {
      lineNumber += 1;
      column = 1;
    } else {
      column += 1;
    }
  }
  return { lineNumber, column };
}

export function lineTextAt(text: string, lineNumber: number): string {
  const lines = text.split(/\r?\n/);
  return lines[lineNumber - 1] ?? "";
}

export function isInsideFencedCodeBlock(text: string, index: number): boolean {
  const lines = text.slice(0, index).split(/\r?\n/);
  let openFence = false;
  for (const line of lines) {
    const trimmed = line.trimStart();
    if (trimmed.startsWith("```") || trimmed.startsWith("~~~")) openFence = !openFence;
  }
  return openFence;
}

export function isInsideInlineCode(text: string, index: number): boolean {
  const { lineNumber, column } = lineColumnAt(text, index);
  const line = lineTextAt(text, lineNumber);
  const before = line.slice(0, Math.max(0, column - 1));
  const after = line.slice(Math.max(0, column - 1));
  return (before.match(/`/g)?.length ?? 0) % 2 === 1 && (after.match(/`/g)?.length ?? 0) > 0;
}

export function isExplanatoryLiteralBackslashN(text: string, index: number): boolean {
  if (isInsideFencedCodeBlock(text, index) || isInsideInlineCode(text, index)) return true;
  const window = text.slice(Math.max(0, index - 90), Math.min(text.length, index + 90)).toLowerCase();
  return [
    "字面量",
    "说明",
    "示例",
    "举例",
    "提到",
    "引用",
    "文本",
    "字符串",
    "escape",
    "literal",
    "example",
    "mention",
    "quoted",
    "反斜杠",
  ].some((keyword) => window.includes(keyword));
}

export function cleanupEscapedText(text: string): string {
  return text
    .replace(/\\r\\n/g, "\n")
    .replace(/\\n/g, "\n")
    .replace(/\\t/g, "\t")
    .replace(/\\x0a/gi, "\n")
    .replace(/\\x1b/gi, "")
    .replace(/\\u001b/gi, "")
    .replace(/\\033/g, "")
    .replace(/\\`/g, "`");
}

export function escapeBodyId(bodyKind: EscapeBodyKind, issueNumber: number, issueId: number, commentId?: number): string {
  return bodyKind === "issue-body"
    ? `issue:${issueNumber}:body:${issueId}`
    : `issue:${issueNumber}:comment:${commentId ?? "unknown"}`;
}

export function textFindingPatterns(): EscapePatternDefinition[] {
  return [
    {
      kind: "literal-backslash-n",
      pattern: /\\n/g,
      description: "literal backslash-n",
    },
    {
      kind: "literal-backslash-t",
      pattern: /\\t/g,
      description: "literal backslash-t",
    },
    {
      kind: "escaped-backtick",
      pattern: /\\`/g,
      description: "escaped backtick",
    },
    {
      kind: "shell-escaped-newline",
      pattern: /\\r\\n|\\012|\\x0a/gi,
      description: "shell escaped newline",
    },
    {
      kind: "ansi-escape-literal",
      pattern: /\\x1b|\\u001b|\\033/gi,
      description: "ANSI escape literal",
    },
  ];
}

export function bodyRiskFindings(kind: EscapeBodyKind, issueNumber: number, issueId: number, body: string | null, url: string, commentId?: number): EscapeMatchFinding[] {
  const bodyChars = body === null ? null : body.length;
  const bodyTrimmedChars = body === null ? null : body.trim().length;
  const location = {
    type: kind === "issue-body" ? "issue" as const : "comment" as const,
    bodyKind: kind,
    bodyId: escapeBodyId(kind, issueNumber, issueId, commentId),
    issueNumber,
    issueId,
    ...(commentId === undefined ? {} : { commentId }),
    url,
    bodyChars,
    bodyTrimmedChars,
  };
  if (body === null) {
    return [{
      ...location,
      kind: "null-body",
      classification: "risk",
      severity: "high",
      reason: "GitHub returned a null body",
      snippet: "",
      lineNumber: 0,
      column: 0,
      match: "",
    }];
  }
  const trimmed = body.trim();
  if (trimmed.toLowerCase() === "null") {
    return [{
      ...location,
      kind: "literal-null-body",
      classification: "risk",
      severity: "high",
      reason: "body text is the literal string null",
      snippet: escapeSnippet(body, 0),
      lineNumber: 1,
      column: 1,
      match: "null",
    }];
  }
  if (trimmed.length === 0) {
    return [{
      ...location,
      kind: "blank-body",
      classification: "risk",
      severity: "medium",
      reason: "body is blank after trimming",
      snippet: "",
      lineNumber: 0,
      column: 0,
      match: "",
    }];
  }
  if (trimmed.length < MIN_SAFE_BODY_SCAN_CHARS) {
    return [{
      ...location,
      kind: "short-body",
      classification: "risk",
      severity: "medium",
      reason: `body is shorter than the safe guard threshold (${MIN_SAFE_BODY_SCAN_CHARS} chars)`,
      snippet: escapeSnippet(body, 0),
      lineNumber: 1,
      column: 1,
      match: "",
    }];
  }
  return [];
}

export function scanText(text: string, patterns: EscapePatternDefinition[], issueNumber: number, issueId: number, bodyKind: EscapeBodyKind, url: string, commentId?: number): EscapeMatchFinding[] {
  const findings: EscapeMatchFinding[] = [];
  const counts = new Map<string, number>();
  for (const item of patterns) {
    item.pattern.lastIndex = 0;
    const matches = text.match(item.pattern);
    counts.set(item.kind, matches === null ? 0 : matches.length);
  }
  for (const item of patterns) {
    item.pattern.lastIndex = 0;
    let match = item.pattern.exec(text);
    while (match !== null) {
      const { lineNumber, column } = lineColumnAt(text, match.index);
      const explanatory = item.kind === "literal-backslash-n" && counts.get(item.kind) === 1 && isExplanatoryLiteralBackslashN(text, match.index);
      const classification: EscapeFindingClassification = explanatory ? "explanatory-mention" : "suspected-pollution";
      const severity: EscapeFindingSeverity = explanatory ? "low" : item.kind === "ansi-escape-literal" ? "high" : "medium";
      findings.push({
        type: bodyKind === "issue-body" ? "issue" : "comment",
        bodyKind,
        bodyId: escapeBodyId(bodyKind, issueNumber, issueId, commentId),
        issueNumber,
        issueId,
        ...(commentId === undefined ? {} : { commentId }),
        url,
        kind: item.kind,
        classification,
        severity,
        reason: explanatory
          ? "literal backslash-n appears in explanatory context"
          : `matched ${item.description}`,
        snippet: escapeSnippet(text, match.index),
        lineNumber,
        column,
        match: match[0],
        bodyChars: text.length,
        bodyTrimmedChars: text.trim().length,
        ...(classification === "suspected-pollution"
          ? { cleanupPreview: { before: escapeSnippet(text, match.index), after: escapeSnippet(cleanupEscapedText(text), match.index) } }
          : {}),
      });
      if (findings.length >= ISSUE_SCAN_MAX_FINDINGS) return findings;
      match = item.pattern.exec(text);
    }
  }
  return findings;
}

export function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupSuggestion[] {
  const groups = new Map<string, EscapeMatchFinding[]>();
  for (const finding of findings) {
    if (finding.type === "comment-scan-error") continue;
    if (finding.classification !== "suspected-pollution" && finding.kind !== "short-body" && finding.kind !== "null-body" && finding.kind !== "blank-body" && finding.kind !== "literal-null-body") continue;
    const locatorKey = `${finding.bodyKind}:${finding.issueNumber}:${finding.issueId}:${finding.commentId ?? ""}`;
    const list = groups.get(locatorKey);
    if (list === undefined) groups.set(locatorKey, [finding]);
    else list.push(finding);
  }

  const suggestions: EscapeCleanupSuggestion[] = [];
  for (const group of groups.values()) {
    const first = group[0];
    const suspected = group.some((finding) => finding.classification === "suspected-pollution");
    const bodyRisk = group.some((finding) => finding.classification === "risk");
    suggestions.push({
      type: first.bodyKind,
      bodyId: first.bodyId,
      issueNumber: first.issueNumber,
      issueId: first.issueId,
      ...(first.commentId === undefined ? {} : { commentId: first.commentId }),
      url: first.url,
      classification: suspected ? "suspected-pollution" : "risk",
      action: first.bodyKind === "issue-body"
        ? (suspected ? "rewrite-issue-body-with-body-stdin" : "review-body-length")
        : "review-comment-manually",
      reason: suspected
        ? "suspected shell escape pollution should be rewritten from heredoc/stdin"
        : bodyRisk
          ? "body length/null risk should be reviewed before any write"
          : "no cleanup needed",
      findings: group.map((finding) => ({
        kind: finding.kind,
        classification: finding.classification,
        severity: finding.severity,
        lineNumber: finding.lineNumber,
        column: finding.column,
        snippet: finding.snippet,
        match: finding.match,
      })),
      ...(first.bodyKind === "issue-body"
        ? {
          plannedCommand: `bun scripts/cli.ts gh issue update ${first.issueNumber} --mode replace --body-stdin --dry-run <<'EOF'\n<cleaned body>\nEOF`,
          bodyInputHint: "pass cleaned Markdown through --body-stdin with a quoted heredoc; --body-file is only for reusable files",
          preview: {
            before: first.cleanupPreview?.before ?? first.snippet,
            after: first.cleanupPreview?.after ?? first.snippet,
          },
        }
        : {
          preview: {
            before: first.cleanupPreview?.before ?? first.snippet,
            after: first.cleanupPreview?.after ?? first.snippet,
          },
        }),
    });
  }
  return suggestions;
}

export async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> {
  const issues = await listIssues(token, repo, "all", limit);
  if (isGitHubError(issues)) return commandError(commandName, repo, issues);
  const issueOnly = issues.items;

  const patterns = textFindingPatterns();
  const findings: EscapeScanEntry[] = [];
  let scannedComments = 0;
  for (const issue of issueOnly) {
    findings.push(...bodyRiskFindings("issue-body", issue.number, issue.id, issue.body, issue.html_url));
    findings.push(...scanText(issue.body ?? "", patterns, issue.number, issue.id, "issue-body", issue.html_url));
    const comments = await listIssueComments(token, repo, issue.number);
    if (isGitHubError(comments)) {
      findings.push({
        type: "comment-scan-error",
        bodyKind: "comment-body",
        bodyId: escapeBodyId("comment-body", issue.number, issue.id),
        issueNumber: issue.number,
        issueId: issue.id,
        url: issue.html_url,
        degradedReason: comments.degradedReason,
        runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
        details: comments,
      });
      continue;
    }
    for (const comment of comments) {
      scannedComments += 1;
      findings.push(...bodyRiskFindings("comment-body", issue.number, issue.id, comment.body, comment.html_url, comment.id));
      findings.push(...scanText(comment.body ?? "", patterns, issue.number, issue.id, "comment-body", comment.html_url, comment.id));
    }
  }
  const cleanupSuggestions = summarizeCleanupSuggestion(findings);
  const summary = findings.reduce((acc, finding) => {
    if (finding.type === "comment-scan-error") {
      acc.commentScanErrors += 1;
      return acc;
    }
    if (finding.classification === "suspected-pollution") acc.suspectedPollution += 1;
    if (finding.classification === "explanatory-mention") acc.explanatoryMention += 1;
    if (finding.classification === "risk") acc.risk += 1;
    if (finding.kind === "null-body" || finding.kind === "literal-null-body" || finding.kind === "blank-body" || finding.kind === "short-body") acc.bodyRisks += 1;
    return acc;
  }, { suspectedPollution: 0, explanatoryMention: 0, risk: 0, bodyRisks: 0, commentScanErrors: 0 });
  return {
    ok: true,
    command: commandName,
    repo,
    dryRun,
    planned: true,
    scannedIssues: issueOnly.length,
    rawIssues: issues.rawCount,
    pagination: issueListPaginationSummary(issues),
    scannedComments,
    findings,
    cleanupSuggestions,
    summary: {
      findings: findings.length,
      suggestions: cleanupSuggestions.length,
      ...summary,
    },
    note: dryRun
      ? "Dry-run cleanup planning only; no issue or comment content was modified."
      : "Read-only scan; no issue or comment content was modified.",
  };
}
