// Exports of a page check or a site scan: Markdown for one issue, CSV, JSON, printable HTML and SARIF.
import { AUTOMATED_LIMIT_NOTE } from "./features.js";
import { PRODUCT } from "./product.js";
import { countByImpact } from "./axe.js";
import type { Comparison, Impact, Issue, IssueStatus, PageResult, ScanComparison, SiteScan } from "./types.js";
import { IMPACTS } from "./types.js";

/** Version of the JSON report shape; raise it on any change that is not a pure addition. */
export const JSON_REPORT_VERSION = 1;

function isSiteScan(r: PageResult | SiteScan): r is SiteScan {
  return "pages" in r;
}

// ---------- Markdown ----------

/** A code span that survives backticks inside the code. */
function codeSpan(text: string): string {
  const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length));
  const fence = "`".repeat(longest + 1);
  const pad = text.startsWith("`") || text.endsWith("`") ? " " : "";
  return `${fence}${pad}${text}${pad}${fence}`;
}

function codeBlock(text: string, lang: string): string {
  const longest = Math.max(2, ...(text.match(/`+/g) ?? []).map((run) => run.length));
  const fence = "`".repeat(longest + 1);
  return `${fence}${lang}\n${text}\n${fence}`;
}

/** One issue as Markdown for a ticket. */
export function issueToMarkdown(issue: Issue, pageUrl: string): string {
  const { text } = issue;
  const lines = [
    `### ${text.title}`,
    "",
    `- Page: <${pageUrl}>`,
    `- Element: ${codeSpan(issue.selector)}`,
    `- Impact: ${issue.impact}`,
    `- Level: ${issue.level === "best-practice" ? "best practice" : `WCAG ${issue.level}`}`,
  ];
  for (const c of issue.criteria) lines.push(`- WCAG ${c.sc} ${c.name} (${c.level}): <${c.url}>`);
  lines.push(`- Rule: ${codeSpan(issue.ruleId)} <${issue.helpUrl}>`, "");
  lines.push(`**What is wrong:** ${text.whatIsWrong}`, "", `**Who is affected:** ${text.whoIsAffected}`, "");
  if (issue.contrast) {
    const c = issue.contrast;
    lines.push(
      `**Contrast:** ${c.ratio}:1, needs ${c.required}:1 (text ${c.foreground} on ${c.background}).` +
        (c.suggestion ? ` A foreground that passes: ${c.suggestion}.` : ""),
      "",
    );
  }
  lines.push("**How to fix:**", "", ...text.fixSteps.map((step, i) => `${i + 1}. ${step}`), "");
  if (text.example) {
    lines.push("Before:", "", codeBlock(text.example.before, "html"), "", "After:", "", codeBlock(text.example.after, "html"), "");
  }
  lines.push("Element markup:", "", codeBlock(issue.snippet, "html"), "");
  return lines.join("\n");
}

// ---------- CSV ----------

const CSV_HEADER = [
  "page_url",
  "page_title",
  "page_error",
  "rule_id",
  "impact",
  "level",
  "wcag",
  "title",
  "selector",
  "snippet",
  "what_is_wrong",
  "who_is_affected",
  "fix_steps",
  "help_url",
  "contrast_ratio",
  "contrast_required",
  "foreground",
  "background",
  "suggested_foreground",
  "key",
];

/**
 * One RFC 4180 field. Cells that a spreadsheet would read as a formula get a leading apostrophe, because the
 * snippet and texts come from the checked page and must not run as formulas when the file is opened.
 */
export function csvCell(value: string | number | undefined): string {
  let s = value === undefined ? "" : String(value);
  if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
  return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}

function csvRows(pageUrl: string, pageTitle: string, issues: readonly Issue[]): string[][] {
  return issues.map((i) => [
    pageUrl,
    pageTitle,
    "",
    i.ruleId,
    i.impact,
    i.level,
    i.criteria.map((c) => c.sc).join("; "),
    i.text.title,
    i.selector,
    i.snippet,
    i.text.whatIsWrong,
    i.text.whoIsAffected,
    i.text.fixSteps.map((s, n) => `${n + 1}. ${s}`).join("\n"),
    i.helpUrl,
    i.contrast ? String(i.contrast.ratio) : "",
    i.contrast ? String(i.contrast.required) : "",
    i.contrast?.foreground ?? "",
    i.contrast?.background ?? "",
    i.contrast?.suggestion ?? "",
    i.key,
  ]);
}

function toCsv(rows: string[][]): string {
  return [CSV_HEADER, ...rows].map((row) => row.map(csvCell).join(",")).join("\r\n") + "\r\n";
}

export function pageResultToCsv(r: PageResult): string {
  return toCsv(csvRows(r.url, r.title, r.issues));
}

/** One row per issue; a page that could not be checked gets one row with its error. */
export function siteScanToCsv(s: SiteScan): string {
  const rows = s.pages.flatMap((p) => {
    if (p.error === undefined) return csvRows(p.url, p.title, p.issues);
    const row: string[] = CSV_HEADER.map(() => "");
    row[0] = p.url;
    row[1] = p.title;
    row[2] = p.error;
    return [row];
  });
  return toCsv(rows);
}

// ---------- JSON ----------

export function toJsonReport(r: PageResult | SiteScan): string {
  const report = isSiteScan(r)
    ? { format: "waysill-report", version: JSON_REPORT_VERSION, kind: "site", note: AUTOMATED_LIMIT_NOTE, site: r }
    : { format: "waysill-report", version: JSON_REPORT_VERSION, kind: "page", note: AUTOMATED_LIMIT_NOTE, page: r };
  return `${JSON.stringify(report, null, 2)}\n`;
}

// ---------- HTML ----------

export function escapeHtml(text: string): string {
  return text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}

/** Links only to web addresses, so a crafted URL in stored data cannot become a script link. */
function link(url: string, label: string): string {
  return /^https?:\/\//i.test(url) ? `<a href="${escapeHtml(url)}">${escapeHtml(label)}</a>` : escapeHtml(label);
}

const STYLE = `
body{font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:#1a1a1a;margin:2rem auto;max-width:60rem;padding:0 1rem}
h1{font-size:1.6rem;margin:0 0 .25rem}h2{font-size:1.25rem;margin:2rem 0 .5rem;border-bottom:2px solid #ddd;padding-bottom:.25rem}
h3{font-size:1.05rem;margin:0 0 .5rem}
.meta{color:#555;margin:0 0 1rem}.note{background:#fff8e1;border-left:4px solid #b26a00;padding:.5rem .75rem}
table.counts{border-collapse:collapse;margin:1rem 0}table.counts th,table.counts td{border:1px solid #ccc;padding:.25rem .75rem;text-align:left}
.issue{border:1px solid #ccc;border-radius:6px;padding:.75rem 1rem;margin:1rem 0;break-inside:avoid}
.tags span{display:inline-block;border:1px solid #888;border-radius:3px;padding:0 .4rem;margin-right:.4rem;font-size:.85rem}
.impact-critical{border-color:#a50e0e!important;color:#a50e0e}.impact-serious{border-color:#b04500!important;color:#8a3700}
.status-new{background:#a50e0e;color:#fff;border-color:#a50e0e!important}.status-unchanged{color:#444}
.status-fixed{background:#1e6b2e;color:#fff;border-color:#1e6b2e!important}
pre{background:#f5f5f5;padding:.5rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:.9em}
.swatch{display:inline-block;width:1em;height:1em;border:1px solid #333;vertical-align:middle}
@media print{body{margin:0;max-width:none}a{color:inherit}.issue{page-break-inside:avoid}}
`;

function countsTable(counts: Record<Impact, number>): string {
  const cells = IMPACTS.map((i) => `<tr><th scope="row">${i}</th><td>${counts[i]}</td></tr>`).join("");
  return `<table class="counts"><caption>Issues by impact</caption><tbody>${cells}</tbody></table>`;
}

/** A colour value with a swatch; the swatch is drawn only for a plain hex colour, so stored data cannot inject CSS. */
function colour(value: string): string {
  const swatch = /^#[0-9a-f]{6}$/i.test(value) ? `<span class="swatch" style="background:${value}"></span> ` : "";
  return `${swatch}<code>${escapeHtml(value)}</code>`;
}

function issueHtml(issue: Issue, status?: IssueStatus): string {
  const t = issue.text;
  const tags = [
    `<span class="impact-${escapeHtml(issue.impact)}">${escapeHtml(issue.impact)}</span>`,
    `<span>${issue.level === "best-practice" ? "best practice" : `WCAG ${escapeHtml(issue.level)}`}</span>`,
    status ? `<span class="status-${status}">${status}</span>` : "",
  ].join("");
  const criteria = issue.criteria.map((c) => `<li>${link(c.url, `${c.sc} ${c.name} (${c.level})`)}</li>`).join("");
  const c = issue.contrast;
  const contrast = c
    ? `<p><strong>Contrast:</strong> ${escapeHtml(String(c.ratio))}:1, needs ${escapeHtml(String(c.required))}:1. ` +
      `Text ${colour(c.foreground)} on ${colour(c.background)}.` +
      (c.suggestion ? ` A foreground that passes: ${colour(c.suggestion)}.` : "") +
      `</p>`
    : "";
  const example = t.example
    ? `<p>Before:</p><pre><code>${escapeHtml(t.example.before)}</code></pre>` +
      `<p>After:</p><pre><code>${escapeHtml(t.example.after)}</code></pre>`
    : "";
  return (
    `<section class="issue"><h3>${escapeHtml(t.title)}</h3><p class="tags">${tags}</p>` +
    `<p><strong>Element:</strong> <code>${escapeHtml(issue.selector)}</code></p>` +
    `<pre><code>${escapeHtml(issue.snippet)}</code></pre>` +
    `<p><strong>What is wrong:</strong> ${escapeHtml(t.whatIsWrong)}</p>` +
    `<p><strong>Who is affected:</strong> ${escapeHtml(t.whoIsAffected)}</p>` +
    contrast +
    `<p><strong>How to fix:</strong></p><ol>${t.fixSteps.map((s) => `<li>${escapeHtml(s)}</li>`).join("")}</ol>` +
    example +
    (criteria ? `<ul>${criteria}</ul>` : "") +
    `<p>Rule ${link(issue.helpUrl, issue.ruleId)}</p></section>`
  );
}

function issuesHtml(issues: readonly Issue[], comparison: Comparison | undefined): string {
  const unchanged = new Set(comparison?.unchanged.map((i) => i.key));
  const body = issues
    .map((i) => issueHtml(i, comparison ? (unchanged.has(i.key) ? "unchanged" : "new") : undefined))
    .join("");
  const fixed =
    comparison && comparison.fixed.length > 0
      ? `<h3>Fixed since the previous run (${comparison.fixed.length})</h3>` +
        comparison.fixed.map((i) => issueHtml(i, "fixed")).join("")
      : "";
  return (issues.length === 0 ? "<p>No issues found by the automated checks.</p>" : body) + fixed;
}

function page(title: string, body: string): string {
  return (
    `<!doctype html><html lang="en"><head><meta charset="utf-8">` +
    `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:">` +
    `<meta name="viewport" content="width=device-width, initial-scale=1">` +
    `<title>${escapeHtml(title)}</title><style>${STYLE}</style></head><body><main>${body}</main></body></html>\n`
  );
}

function settingsLine(r: PageResult | SiteScan): string {
  return `WCAG 2.2 level ${escapeHtml(r.settings.level)}${r.settings.bestPractice ? " and best-practice rules" : ""}`;
}

/**
 * A self-contained printable report: inline CSS, no scripts, every text escaped. With a comparison, each issue is
 * marked new or unchanged and fixed issues are listed per page.
 */
export function toHtmlReport(r: PageResult | SiteScan, comparison?: Comparison | ScanComparison): string {
  const note = `<p class="note">${escapeHtml(AUTOMATED_LIMIT_NOTE)}</p>`;
  if (!isSiteScan(r)) {
    const c = comparison && !("byPage" in comparison) ? comparison : undefined;
    const untested =
      r.untestedFrames.length > 0
        ? `<p>Frames not tested: ${r.untestedFrames.map((f) => `<code>${escapeHtml(f)}</code>`).join(", ")}</p>`
        : "";
    const headings = r.outline.headings
      .map((h) => `<li>h${escapeHtml(String(h.level))}${h.skipped ? " (level skipped)" : ""}: ${escapeHtml(h.text)}</li>`)
      .join("");
    const outline =
      r.outline.problems.length + r.outline.headings.length > 0
        ? `<h2>Structure</h2>` +
          (r.outline.problems.length > 0 ? `<ul>${r.outline.problems.map((p) => `<li>${escapeHtml(p)}</li>`).join("")}</ul>` : "") +
          (headings ? `<h3>Headings</h3><ul>${headings}</ul>` : "")
        : "";
    return page(
      `${PRODUCT.name} report: ${r.title || r.url}`,
      `<h1>${escapeHtml(PRODUCT.name)} accessibility report</h1>` +
        `<p class="meta">${link(r.url, r.title || r.url)}<br>Checked ${escapeHtml(r.checkedAt)} against ${settingsLine(r)}</p>` +
        note +
        countsTable(r.counts) +
        untested +
        outline +
        `<h2>Issues (${r.issues.length})</h2>` +
        issuesHtml(r.issues, c),
    );
  }
  const c = comparison && "byPage" in comparison ? comparison : undefined;
  const counts = countByImpact(r.pages.flatMap((p) => p.issues));
  const totals = c
    ? `<p>Since the previous scan: ${c.totals.new} new, ${c.totals.unchanged} unchanged, ${c.totals.fixed} fixed.</p>`
    : "";
  const pages = r.pages
    .map(
      (p) =>
        `<h2>${link(p.url, p.title || p.url)}</h2>` +
        (p.error !== undefined
          ? `<p>This page could not be checked: ${escapeHtml(p.error)}</p>`
          : issuesHtml(p.issues, c?.byPage[p.url])),
    )
    .join("");
  return page(
    `${PRODUCT.name} site report: ${r.origin}`,
    `<h1>${escapeHtml(PRODUCT.name)} site accessibility report</h1>` +
      `<p class="meta">${escapeHtml(r.origin)}: ${r.pages.length} pages${r.stopped ? " (scan stopped early)" : ""}<br>` +
      `Started ${escapeHtml(r.startedAt)}${r.finishedAt ? `, finished ${escapeHtml(r.finishedAt)}` : ""}, ` +
      `against ${settingsLine(r)}</p>` +
      note +
      countsTable(counts) +
      totals +
      pages,
  );
}

// ---------- SARIF ----------

const SARIF_LEVEL: Record<Impact, "error" | "warning" | "note"> = {
  critical: "error",
  serious: "error",
  moderate: "warning",
  minor: "note",
};

/** SARIF 2.1.0 log with one run; rules are listed once, results point to the file and the element selector. */
export function toSarif(files: readonly { uri: string; issues: readonly Issue[] }[]): string {
  const rules: object[] = [];
  const ruleIndex = new Map<string, number>();
  const results: object[] = [];
  for (const file of files) {
    for (const issue of file.issues) {
      let index = ruleIndex.get(issue.ruleId);
      if (index === undefined) {
        index = rules.length;
        ruleIndex.set(issue.ruleId, index);
        const steps = issue.text.fixSteps.map((s, n) => `${n + 1}. ${s}`).join("\n");
        rules.push({
          id: issue.ruleId,
          shortDescription: { text: issue.text.title },
          fullDescription: { text: issue.text.whatIsWrong },
          helpUri: issue.helpUrl,
          help: { text: steps, markdown: steps },
          properties: {
            tags: ["accessibility", issue.level, ...issue.criteria.map((c) => `WCAG ${c.sc}`)],
          },
        });
      }
      results.push({
        ruleId: issue.ruleId,
        ruleIndex: index,
        level: SARIF_LEVEL[issue.impact],
        message: { text: `${issue.text.title}. ${issue.text.whatIsWrong}` },
        locations: [
          {
            physicalLocation: { artifactLocation: { uri: file.uri } },
            logicalLocations: [{ fullyQualifiedName: issue.selector, kind: "element" }],
          },
        ],
        partialFingerprints: { "waysillKey/v1": issue.key },
        properties: { impact: issue.impact, criteria: issue.criteria.map((c) => `${c.sc} ${c.name} (${c.level})`).join("; ") },
      });
    }
  }
  const log = {
    $schema: "https://json.schemastore.org/sarif-2.1.0.json",
    version: "2.1.0",
    runs: [{ tool: { driver: { name: PRODUCT.name, informationUri: PRODUCT.urls.home, rules } }, results }],
  };
  return `${JSON.stringify(log, null, 2)}\n`;
}
