#!/usr/bin/env node

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";

const MARKER = "<!-- mehen-metrics -->";
const DEFAULT_TITLE = "## 📊 Source Code Metrics";
const FOOTER_PREFIX =
  "> Generated by [mehen](https://github.com/ophi-dev/mehen)";
const FOOTER_SUFFIX = "— the code quality watcher.";
const LOWER_IS_BETTER = "lower-is-better";
const HIGHER_IS_BETTER = "higher-is-better";

const DEFAULT_TEST_EXCLUDES = [
  // Go
  "**/*_test.go",
  // Python
  "**/test_*.py",
  "**/*_test.py",
  "**/tests/**",
  "**/test/**",
  // JavaScript / TypeScript
  "**/__tests__/**",
  "**/*.test.js",
  "**/*.test.jsx",
  "**/*.test.ts",
  "**/*.test.tsx",
  "**/*.test.mjs",
  "**/*.test.cjs",
  "**/*.spec.js",
  "**/*.spec.jsx",
  "**/*.spec.ts",
  "**/*.spec.tsx",
  "**/*.spec.mjs",
  "**/*.spec.cjs",
  // Ruby
  "**/*_test.rb",
  "**/*_spec.rb",
  "**/spec/**",
];

const METRIC_ALIASES = new Map([
  ["functions", "nom.functions"],
  ["nom", "nom.functions"],
  ["lloc", "loc.lloc"],
  ["loc", "loc.lloc"],
  ["halsteadvol", "halstead.volume"],
  ["halsteadvolume", "halstead.volume"],
]);

if (isEntrypoint()) {
  main().catch((error) => {
    console.error(error instanceof Error ? error.message : String(error));
    process.exit(1);
  });
}

function isEntrypoint() {
  return (
    process.argv[1] &&
    path.resolve(process.argv[1]) ===
      path.resolve(fileURLToPath(import.meta.url))
  );
}

async function main() {
  const thresholds = parseThresholds(input("THRESHOLDS"));
  const cli = prepareMehen();
  const version = detectMehenVersion(cli);
  const diffArgs = buildDiffArgs();
  const diff = runMehen(cli, diffArgs, { acceptGateOutput: isGateFailureReport });
  const reportsDir = process.env.RUNNER_TEMP || os.tmpdir();
  const reportJson = path.join(reportsDir, `mehen-diff-${Date.now()}.json`);
  fs.writeFileSync(reportJson, `${diff.stdout.trim()}\n`, "utf8");

  const diffs = parseDiffJson(diff.stdout);
  const gateViolations = parseGateViolations(diff.stdout);
  const context = readGithubContext();
  const violations = collectThresholdViolations(diffs, thresholds);
  let markdown = renderMarkdown(
    diffs,
    context,
    thresholds,
    violations,
    version,
    gateViolations,
  );

  // Phase F (§39): `mehen diff --output-format markdown` emits a
  // `<!-- mehen-docs -->` block whenever a changed Markdown file is in
  // scope. The JSON payload already says whether that is the case
  // (its `markdown` array), so the second invocation — needed only
  // for the *rendered* section — is skipped entirely when no Markdown
  // file changed, and runs history-free when it is needed.
  const docsSection = diffJsonHasDocs(diff.stdout)
    ? fetchMarkdownDocsSection(cli, diffArgs)
    : null;
  if (docsSection) {
    markdown = `${markdown.trimEnd()}\n\n${docsSection}\n`;
  }

  const reportMarkdown = path.join(reportsDir, `mehen-report-${Date.now()}.md`);
  fs.writeFileSync(reportMarkdown, markdown, "utf8");

  writeStepSummary(markdown);
  try {
    await maybeComment(markdown, context);
  } catch (error) {
    console.warn(`Unable to publish mehen PR comment: ${error.message}`);
  }

  // The advertised violation count covers both gates: Action-input
  // delta thresholds and repository `mehen.toml` breaches parsed from
  // the diff report.
  setOutput("violations", String(violations.length + gateViolations.length));
  setOutput("report_json", reportJson);
  setOutput("report_markdown", reportMarkdown);

  if (violations.length > 0 && boolInput("FAIL_ON_THRESHOLD", true)) {
    console.error(
      `Mehen threshold check failed with ${violations.length} violation(s).`,
    );
    process.exit(1);
  }

  // A deferred quality-gate failure from the diff itself (configured
  // `mehen.toml` thresholds): the comment and report outputs above
  // landed with the complete report; now the workflow step fails.
  if (diff.gateStatus) {
    console.error(
      `mehen diff exited with status ${diff.gateStatus}: a configured quality gate failed (see the report above).`,
    );
    process.exit(1);
  }
}

function input(name, fallback = "") {
  return process.env[`GHA_MEHEN_${name}`] ?? fallback;
}

function boolInput(name, fallback) {
  const value = input(name).trim().toLowerCase();
  if (!value) {
    return fallback;
  }
  return ["1", "true", "yes", "on"].includes(value);
}

function parseList(value) {
  const trimmed = String(value ?? "").trim();
  if (!trimmed) {
    return [];
  }
  const parts = trimmed.split(/\r?\n|[;,]/);
  return parts.map((part) => part.trim()).filter(Boolean);
}

function buildDiffArgs() {
  const args = ["diff", "--output-format", "json"];
  const from = input("FROM").trim();
  const to = input("TO").trim();
  const metrics = input("METRICS").trim();
  const paths = parseList(input("PATHS"));
  const include = parseList(input("INCLUDE"));
  const exclude = parseList(input("EXCLUDE"));
  if (boolInput("EXCLUDE_TESTS", true)) {
    for (const pattern of DEFAULT_TEST_EXCLUDES) {
      if (!exclude.includes(pattern)) {
        exclude.push(pattern);
      }
    }
  }

  if (from) {
    args.push("--from", from);
  }
  if (to) {
    args.push("--to", to);
  }
  if (metrics) {
    args.push("--metrics", metrics);
  }
  if (paths.length > 0 && !(paths.length === 1 && paths[0] === ".")) {
    args.push("--paths", ...paths);
  }
  if (include.length > 0) {
    args.push("--include", ...include);
  }
  if (exclude.length > 0) {
    args.push("--exclude", ...exclude);
  }
  if (boolInput("SHOW_UNCHANGED", false)) {
    args.push("--show-unchanged");
  }

  return args;
}

function prepareMehen() {
  const method = input("INSTALL_METHOD", "npm").trim().toLowerCase();

  if (method === "npm") {
    const version = input("VERSION").trim();
    const pkg = version ? `mehen@${version}` : "mehen";
    return { command: "npx", args: ["-y", pkg] };
  }

  if (method === "cargo") {
    const actionPath = process.env.GITHUB_ACTION_PATH || process.cwd();
    // The repo root is a virtual workspace manifest (no `[package]`),
    // so `cargo install --path <root>` fails with "found a virtual
    // manifest … instead of a package manifest". `cargo install
    // --path <member>` works against the `mehen` package's directory.
    const cratePath = path.join(actionPath, "crates", "mehen-cli");
    const root = path.join(
      process.env.RUNNER_TEMP || os.tmpdir(),
      "mehen-action-cli",
    );
    const bin = process.platform === "win32" ? "mehen.exe" : "mehen";
    const binPath = path.join(root, "bin", bin);

    if (!fs.existsSync(binPath)) {
      fs.rmSync(root, { recursive: true, force: true });
      runCommand(
        "cargo",
        ["install", "--path", cratePath, "--locked", "--root", root],
        {
          cwd: actionPath,
          stdio: "inherit",
        },
      );
    } else {
      console.log(`Using cached mehen binary at ${binPath}.`);
    }

    return { command: binPath, args: [] };
  }

  if (method === "path") {
    return { command: input("PATH", "mehen").trim() || "mehen", args: [] };
  }

  throw new Error(
    `Unsupported mehen install-method '${method}'. Use npm, cargo, or path.`,
  );
}

function detectMehenVersion(cli) {
  const result = spawnSync(
    cli.command,
    [...cli.args, "--version", "--json"],
    {
      encoding: "utf8",
      maxBuffer: 1024 * 1024,
    },
  );
  if (result.error || result.status !== 0) {
    return "";
  }
  return parseVersionOutput(result.stdout);
}

function parseVersionOutput(stdout) {
  const text = String(stdout ?? "").trim();
  if (!text) {
    return "";
  }
  try {
    const payload = JSON.parse(text);
    if (payload && typeof payload.version === "string") {
      return payload.version.trim();
    }
  } catch {
    // Fall through to return "" so older mehen builds without
    // `--version --json` don't break the action.
  }
  return "";
}

function runMehen(cli, args, options = {}) {
  console.log(`Running: ${[cli.command, ...cli.args, ...args].join(" ")}`);
  const result = spawnSync(cli.command, [...cli.args, ...args], {
    encoding: "utf8",
    maxBuffer: 64 * 1024 * 1024,
  });

  if (result.error) {
    throw result.error;
  }
  if (result.status !== 0) {
    // A nonzero exit that still produced the requested report is a
    // quality-gate failure (`mehen.toml` thresholds exit 1, `--fail-on`
    // documentation gates exit 2), not a broken run: surface the
    // stderr report in the log, keep the stdout report so the PR
    // comment and outputs still land, and let the caller defer the
    // failure. Everything else keeps failing fast.
    if (
      typeof options.acceptGateOutput === "function" &&
      options.acceptGateOutput(result.stdout)
    ) {
      if (result.stderr) {
        process.stderr.write(result.stderr);
      }
      return { ...result, gateStatus: result.status };
    }
    if (result.stdout) {
      console.error(result.stdout);
    }
    if (result.stderr) {
      console.error(result.stderr);
    }
    throw new Error(`mehen exited with status ${result.status}`);
  }
  if (result.stderr) {
    process.stderr.write(result.stderr);
  }
  return result;
}

/**
 * Whether a failing `mehen diff --output-format json` invocation is a
 * configured quality-gate exit: the payload must be complete AND carry
 * the explicit `threshold_violations` signal the CLI emits only when a
 * `mehen.toml` gate fired. An analysis failure also exits 1 with
 * well-formed JSON but without that key — it must keep failing fast
 * instead of publishing a partial report under the wrong reason.
 */
function isGateFailureReport(stdout) {
  try {
    const parsed = JSON.parse(typeof stdout === "string" ? stdout : "");
    return Boolean(
      parsed &&
        Array.isArray(parsed.source_code) &&
        Array.isArray(parsed.threshold_violations) &&
        parsed.threshold_violations.length > 0,
    );
  } catch {
    return false;
  }
}

function runCommand(command, args, options = {}) {
  const result = spawnSync(command, args, {
    encoding: "utf8",
    ...options,
  });
  if (result.error) {
    throw result.error;
  }
  if (result.status !== 0) {
    throw new Error(
      `${command} ${args.join(" ")} exited with status ${result.status}`,
    );
  }
  return result;
}

/**
 * Whether the JSON diff payload carries a documentation section — the
 * signal that a second (markdown-format) invocation would actually
 * yield a `<!-- mehen-docs -->` block worth extracting.
 */
function diffJsonHasDocs(stdout) {
  try {
    const parsed = JSON.parse(typeof stdout === "string" ? stdout : "");
    return Boolean(
      parsed && Array.isArray(parsed.markdown) && parsed.markdown.length > 0,
    );
  } catch {
    return false;
  }
}

/**
 * Re-run `mehen diff --output-format markdown` with the same scope as
 * the JSON run, then carve out the `<!-- mehen-docs -->` section per
 * §39.1. Returns null when the section is absent (no Markdown files in
 * scope) or when the CLI fails — callers treat null as "just publish
 * the source-code section".
 *
 * The rerun is forced history-free: the docs section comes from the
 * documentation pipeline (fixed `markdown.*` columns, never history),
 * and this run's source-code section is discarded — without the
 * override, the default history columns would make the rerun pay for
 * two more full repository walks that feed nothing.
 */
function fetchMarkdownDocsSection(cli, baseArgs) {
  const mdArgs = [...baseArgs];
  const fmtIdx = mdArgs.indexOf("--output-format");
  if (fmtIdx >= 0) {
    mdArgs[fmtIdx + 1] = "markdown";
  }
  const metricsIdx = mdArgs.indexOf("--metrics");
  if (metricsIdx >= 0) {
    mdArgs[metricsIdx + 1] = "cognitive";
  } else {
    mdArgs.push("--metrics", "cognitive");
  }
  let mdResult;
  try {
    // The rerun trips the same configured quality gates as the main
    // diff (exit 1 with the markdown report on stdout); accept any
    // non-empty output so the docs section still reaches the comment.
    mdResult = runMehen(cli, mdArgs, {
      acceptGateOutput: (stdout) =>
        typeof stdout === "string" && stdout.trim().length > 0,
    });
  } catch (error) {
    console.warn(
      `mehen diff --output-format markdown failed (docs section will be omitted): ${error.message}`,
    );
    return null;
  }
  return extractMarkdownDocsSection(mdResult?.stdout ?? "");
}

/**
 * Pure extractor for the `<!-- mehen-docs -->` section so it can be
 * unit-tested without spawning the CLI. Treats any of the following as
 * "no docs section" (returns null):
 *   - input is null, undefined, or whitespace-only,
 *   - the anchor is absent,
 *   - the slice starting at the anchor contains nothing beyond the
 *     anchor itself (e.g. truncated/partial CLI output).
 */
function extractMarkdownDocsSection(stdout) {
  const body = typeof stdout === "string" ? stdout : "";
  if (!body.trim()) {
    return null;
  }
  const anchor = "<!-- mehen-docs -->";
  const start = body.indexOf(anchor);
  if (start < 0) {
    return null;
  }
  // Anchor through end-of-output is the docs section — §39.1 places
  // the anchor at the start of the section and the CLI never emits
  // anything after it.
  const section = body.slice(start).trim();
  // An anchor-only output (no headline/table/callouts after it) is
  // effectively empty — treat as missing rather than publishing a bare
  // comment marker into the sticky PR comment.
  if (section === anchor) {
    return null;
  }
  return section;
}

function parseDiffJson(stdout) {
  try {
    const parsed = JSON.parse(stdout);
    // Phase F (§39) introduced the `{ source_code: [...], markdown?: ... }`
    // shape so a single `mehen diff --output-format json` run can carry
    // both sections. Accept either the new object form or the legacy
    // top-level array for backward compatibility with older mehen CLIs.
    if (Array.isArray(parsed)) {
      return parsed;
    }
    if (parsed && Array.isArray(parsed.source_code)) {
      return parsed.source_code;
    }
    throw new Error(
      "mehen diff JSON output was not an array or {source_code: [...]} object",
    );
  } catch (error) {
    throw new Error(
      `Failed to parse mehen diff JSON: ${error.message}\n${stdout}`,
    );
  }
}

function readGithubContext() {
  let payload = {};
  const eventPath = process.env.GITHUB_EVENT_PATH;
  if (eventPath && fs.existsSync(eventPath)) {
    payload = JSON.parse(fs.readFileSync(eventPath, "utf8"));
  }

  const pullRequest = payload.pull_request;
  return {
    eventName: process.env.GITHUB_EVENT_NAME || "",
    repository: process.env.GITHUB_REPOSITORY || "",
    sha: pullRequest?.head?.sha || process.env.GITHUB_SHA || "",
    baseLabel: pullRequest?.base?.ref || input("FROM").trim() || "base",
    prNumber: pullRequest?.number || payload.number || null,
    token: input("GITHUB_TOKEN").trim() || process.env.GITHUB_TOKEN || "",
  };
}

/**
 * The `threshold_violations` array a gate-failing `mehen diff` embeds
 * in its JSON report (absolute `mehen.toml` breaches at head — see the
 * Configuration docs). Empty for passing runs, older CLIs, and
 * non-JSON output.
 */
function parseGateViolations(stdout) {
  try {
    const parsed = JSON.parse(typeof stdout === "string" ? stdout : "");
    return parsed && Array.isArray(parsed.threshold_violations)
      ? parsed.threshold_violations
      : [];
  } catch {
    return [];
  }
}

function renderMarkdown(
  diffs,
  context,
  thresholds,
  violations,
  version = "",
  gateViolations = [],
) {
  const title = input("COMMENT_TITLE", DEFAULT_TITLE).trim() || DEFAULT_TITLE;
  const scope =
    context.eventName === "pull_request"
      ? ` (this PR vs \`${context.baseLabel}\`)`
      : "";
  let body = `${MARKER}\n${title}${scope}\n\n`;

  let sawNotApplicable = false;

  if (diffs.length === 0) {
    body += "No metric changes detected.\n";
  } else {
    const metrics = unionMetricColumns(diffs);
    body += "| File |";
    for (const metric of metrics) {
      body += ` ${escapeCell(metric.label || metric.name)} |`;
    }
    body += "\n|---|";
    for (const _metric of metrics) {
      body += "---:|";
    }
    body += "\n";

    for (const file of diffs) {
      body += `| ${renderFile(file.path, context)} |`;
      const row = alignFileMetrics(file.metrics || [], metrics);
      for (const metric of row) {
        if (isNotApplicable(metric)) {
          sawNotApplicable = true;
        }
        body += ` ${escapeCell(formatMetricCell(metric, context.baseLabel))} |`;
      }
      body += "\n";
    }
  }

  if (thresholds.size > 0) {
    body += "\n### Thresholds\n\n";
    if (violations.length === 0) {
      body += "All configured thresholds passed.\n";
    } else {
      body += "| File | Metric | Delta | Limit |\n";
      body += "|---|---|---:|---:|\n";
      for (const violation of violations) {
        body += `| ${renderFile(violation.path, context)} | ${escapeCell(violation.label)} | ${formatSigned(violation.delta)} | ${formatNumber(violation.limit)} |\n`;
      }
    }
  }

  // Absolute `mehen.toml` breaches at head: the diff table above may
  // not name these files at all (an unchanged-but-over-limit metric
  // is dropped as an unchanged row), so the failing gate must be
  // spelled out here or the comment would say "no changes" while the
  // workflow step fails.
  if (gateViolations.length > 0) {
    body += "\n### Repository thresholds (mehen.toml)\n\n";
    body += "| File | Metric | Value | Limit | Set by |\n";
    body += "|---|---|---:|---:|---|\n";
    for (const violation of gateViolations) {
      const bound = violation.polarity === "higher_is_better" ? "min" : "max";
      body += `| ${renderFile(violation.path, context)} | ${escapeCell(violation.metric)} | ${formatExactNumber(violation.value)} | ${bound} ${formatExactNumber(violation.limit)} | ${escapeCell(violation.source_table)} |\n`;
    }
  }

  if (sawNotApplicable) {
    body +=
      "\n> `—` indicates the metric does not apply to the file's language.\n";
  }

  body += `\n${renderFooter(version)}\n`;

  return body;
}

function renderFooter(version) {
  const trimmed = String(version ?? "").trim();
  const versionSuffix = trimmed ? ` v${trimmed}` : "";
  return `${FOOTER_PREFIX}${versionSuffix} ${FOOTER_SUFFIX}`;
}

function renderFile(filePath, context) {
  const escaped = escapeCell(filePath);
  if (!context.repository || !context.sha) {
    return escaped;
  }
  const urlPath = String(filePath)
    .split("/")
    .map((part) => encodeURIComponent(part))
    .join("/");
  return `[${escaped}](https://github.com/${context.repository}/blob/${context.sha}/${urlPath})`;
}

// Build the master header as the union of metrics across all files, preserving
// first-seen order. Using `diffs[0].metrics` alone would silently drop any
// metric that only appears in later files (e.g. when the first file omits it
// under the language-applicability rules).
function unionMetricColumns(diffs) {
  const seen = new Map();
  for (const file of diffs) {
    for (const metric of file.metrics || []) {
      const key = metric && (metric.name || metric.label);
      if (key && !seen.has(key)) {
        seen.set(key, metric);
      }
    }
  }
  return [...seen.values()];
}

// Normalize a file's metric list against the header's master list so that a
// file which omits a metric still produces a cell in the row. Missing metrics
// are filled with a placeholder that isNotApplicable() recognizes, so they
// render as `—`.
function alignFileMetrics(fileMetrics, headerMetrics) {
  const byKey = new Map();
  for (const m of fileMetrics) {
    if (m && (m.name || m.label)) {
      byKey.set(m.name || m.label, m);
    }
  }
  return headerMetrics.map((h) => {
    const key = h.name || h.label;
    const found = byKey.get(key);
    if (found) {
      return found;
    }
    return {
      name: h.name,
      label: h.label,
      not_applicable: true,
      current: null,
      baseline: null,
      delta: 0,
      polarity: h.polarity,
    };
  });
}

function isNotApplicable(metric) {
  if (!metric) {
    return true;
  }
  if (metric.not_applicable === true) {
    return true;
  }
  const current = metric.current;
  const baseline = metric.baseline;
  const missing = (v) => v === null || v === undefined;
  return missing(current) && missing(baseline);
}

function formatMetricCell(metric, baseLabel) {
  if (isNotApplicable(metric)) {
    return "—";
  }
  // A side flagged unavailable carries a numeric `0.0` placeholder
  // that is *not* a measurement (static analysis was impossible for
  // that side — undecodable content, blocking parse diagnostic, or a
  // feature-gated analyzer). Render `n/a` and claim no trend, exactly
  // like the native CLI table.
  const currentUnavailable = metric.current_unavailable === true;
  const baselineUnavailable = metric.baseline_unavailable === true;
  const current = currentUnavailable ? "n/a" : formatNumber(metric.current);
  if (metric.is_new) {
    return `${current} \u{1F195}`;
  }
  if (metric.is_deleted) {
    if (baselineUnavailable) {
      return "0 (was: n/a)";
    }
    return `0 (was: ${formatNumber(metric.baseline)}) ${trendEmoji(metric)}`;
  }
  if (currentUnavailable || baselineUnavailable) {
    if (currentUnavailable && baselineUnavailable) {
      return "n/a";
    }
    const baseline = baselineUnavailable ? "n/a" : formatNumber(metric.baseline);
    return `${current} (${baseLabel}: ${baseline})`;
  }
  if (Number(metric.delta) === 0) {
    return `${current} \u{26AA}`;
  }
  return `${current} (${baseLabel}: ${formatNumber(metric.baseline)}) ${trendEmoji(metric)}`;
}

function trendEmoji(metric) {
  const delta = Number(metric.delta);
  if (delta === 0) {
    return "\u{26AA}";
  }
  const polarity = metric.polarity || inferPolarity(metric.name);
  if (polarity === HIGHER_IS_BETTER) {
    return delta > 0 ? "\u{1F7E2}" : "\u{1F534}";
  }
  return delta > 0 ? "\u{1F534}" : "\u{1F7E2}";
}

function collectThresholdViolations(diffs, thresholds) {
  const violations = [];
  if (thresholds.size === 0) {
    return violations;
  }

  for (const file of diffs) {
    for (const metric of file.metrics || []) {
      if (isNotApplicable(metric)) {
        continue;
      }
      // An unavailable side means the delta is a placeholder, not a
      // measured change — never a threshold violation.
      if (metric.current_unavailable === true || metric.baseline_unavailable === true) {
        continue;
      }
      const key = canonicalMetricName(metric.name || metric.label || "");
      const labelKey = canonicalMetricName(metric.label || metric.name || "");
      const limit = thresholds.get(key) ?? thresholds.get(labelKey);
      if (limit === undefined) {
        continue;
      }

      const adverseDelta = getAdverseDelta(metric);
      if (adverseDelta > limit) {
        violations.push({
          path: file.path,
          label: metric.label || metric.name,
          delta: Number(metric.delta),
          limit,
        });
      }
    }
  }

  return violations;
}

function getAdverseDelta(metric) {
  const delta = Number(metric.delta);
  const polarity = metric.polarity || inferPolarity(metric.name);
  if (polarity === HIGHER_IS_BETTER) {
    return delta < 0 ? Math.abs(delta) : 0;
  }
  return delta > 0 ? delta : 0;
}

function parseThresholds(value) {
  const thresholds = new Map();
  for (const raw of parseList(value)) {
    const match = raw.match(
      /^([^:=<>]+)\s*(?:<=|=|:)\s*([0-9]+(?:\.[0-9]+)?)$/,
    );
    if (!match) {
      throw new Error(`Invalid threshold '${raw}'. Expected metric=number.`);
    }
    thresholds.set(canonicalMetricName(match[1]), Number(match[2]));
  }
  return thresholds;
}

function canonicalMetricName(name) {
  const raw = String(name)
    .replace(/[\s_-]+/g, "")
    .replace(/\.+/g, ".");
  // `halstead.n1`/`halstead.N1` and `halstead.n2`/`halstead.N2` are
  // case-distinct published keys (distinct vs total operator/operand
  // counts): lowercasing would collapse a threshold onto the wrong
  // measurement, so their case is preserved verbatim.
  if (/^halstead\.[nN][12]$/.test(raw)) {
    return raw;
  }
  const normalized = raw.toLowerCase();
  return METRIC_ALIASES.get(normalized) || normalized;
}

function inferPolarity(name) {
  const canonical = canonicalMetricName(name);
  return canonical === "mi" || canonical.startsWith("mi.")
    ? HIGHER_IS_BETTER
    : LOWER_IS_BETTER;
}

function formatNumber(value) {
  const number = Number(value);
  if (!Number.isFinite(number)) {
    return String(value);
  }
  return Number.isInteger(number) ? String(number) : number.toFixed(2);
}

/**
 * Shortest exact representation for repository-gate values: rounding
 * both sides of a close crossing to two decimals would render an
 * apparently impossible failure (`0.50` versus `max 0.50` for a
 * `0.504` value over a `0.503` limit).
 */
function formatExactNumber(value) {
  const number = Number(value);
  return Number.isFinite(number) ? String(number) : String(value);
}

function formatSigned(value) {
  const number = Number(value);
  if (!Number.isFinite(number)) {
    return String(value);
  }
  const formatted = formatNumber(number);
  return number > 0 ? `+${formatted}` : formatted;
}

function escapeCell(value) {
  return String(value).replace(/\|/g, "\\|").replace(/\n/g, " ");
}

function writeStepSummary(markdown) {
  const summary = process.env.GITHUB_STEP_SUMMARY;
  if (summary) {
    fs.appendFileSync(summary, `${markdown}\n`, "utf8");
  }
}

async function maybeComment(markdown, context) {
  if (!boolInput("COMMENT", true) || context.eventName !== "pull_request") {
    return;
  }
  if (!context.repository || !context.prNumber) {
    console.log(
      "Skipping mehen PR comment because pull request context is unavailable.",
    );
    return;
  }
  if (!context.token) {
    console.log(
      "Skipping mehen PR comment because github-token is unavailable.",
    );
    return;
  }

  const [owner, repo] = context.repository.split("/");
  const comments = await listComments(
    owner,
    repo,
    context.prNumber,
    context.token,
  );
  const title = input("COMMENT_TITLE", DEFAULT_TITLE).trim() || DEFAULT_TITLE;
  const previous = comments.find(
    (comment) =>
      comment.body?.includes(MARKER) || comment.body?.startsWith(title),
  );

  if (previous) {
    await githubRequest(
      "PATCH",
      `/repos/${owner}/${repo}/issues/comments/${previous.id}`,
      context.token,
      { body: markdown },
    );
    console.log(`Updated mehen metrics comment ${previous.id}.`);
  } else {
    await githubRequest(
      "POST",
      `/repos/${owner}/${repo}/issues/${context.prNumber}/comments`,
      context.token,
      { body: markdown },
    );
    console.log("Created mehen metrics comment.");
  }
}

async function listComments(owner, repo, issueNumber, token) {
  const comments = [];
  for (let page = 1; ; page += 1) {
    const batch = await githubRequest(
      "GET",
      `/repos/${owner}/${repo}/issues/${issueNumber}/comments?per_page=100&page=${page}`,
      token,
    );
    comments.push(...batch);
    if (batch.length < 100) {
      return comments;
    }
  }
}

async function githubRequest(method, apiPath, token, body = undefined) {
  const response = await fetch(`https://api.github.com${apiPath}`, {
    method,
    headers: {
      Accept: "application/vnd.github+json",
      Authorization: `Bearer ${token}`,
      "User-Agent": "mehen-action",
      "X-GitHub-Api-Version": "2022-11-28",
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(
      `GitHub API ${method} ${apiPath} failed: ${response.status} ${text}`,
    );
  }

  if (response.status === 204) {
    return null;
  }

  return response.json();
}

function setOutput(name, value) {
  const output = process.env.GITHUB_OUTPUT;
  if (output) {
    fs.appendFileSync(output, `${name}=${value}\n`, "utf8");
  }
}

export {
  DEFAULT_TEST_EXCLUDES,
  alignFileMetrics,
  canonicalMetricName,
  collectThresholdViolations,
  diffJsonHasDocs,
  extractMarkdownDocsSection,
  formatMetricCell,
  inferPolarity,
  isGateFailureReport,
  isNotApplicable,
  parseGateViolations,
  parseList,
  parseThresholds,
  parseVersionOutput,
  renderFooter,
  unionMetricColumns,
};
