"""SARIF 2.1.0 output for Strix vulnerability reports.

Builds a GitHub code-scanning compatible SARIF document from Strix findings
so CI pipelines can upload findings via ``github/codeql-action/upload-sarif``,
ingest into ASPM platforms, or normalise across scanners.

Schema: SARIF 2.1.0 (OASIS). The output is validated against the official
schema at https://json.schemastore.org/sarif-2.1.0.json in tests.

Integration:
  - ``ReportState._save_artifacts`` calls :func:`write_sarif` to emit a
    ``findings.sarif`` sidecar alongside the existing CSV + markdown + JSON
    artefacts on every save. The call is wrapped in try/except there so a
    SARIF failure never blocks the CSV + markdown + run-record path.

Design notes:
  * Rules are keyed on CWE (``id = CWE-NNN``), falling back to CVE, then
    to finding-id, then to a title slug. CWE values are normalised from
    Strix output variants (``CWE-306``, ``cwe: 306``, ``306``) to the
    canonical ``CWE-NNN`` form so dedup works across runs.
  * SARIF only has three levels (error / warning / note). Strix's five
    severities collapse into them. The raw severity label and CVSS score
    survive in ``result.properties.strix`` for downstream tools that can
    distinguish CRITICAL vs HIGH.
  * GitHub code-scanning uses ``rule.properties['security-severity']``
    (a 0.0-10.0 string) to rank alerts. We populate it from CVSS when
    available, otherwise from a conservative label -> score map.
  * File locations must be repo-relative POSIX paths. Paths that look
    like URIs, absolute paths, or traversal patterns are rejected rather
    than emitted as invalid code-scanning alerts.
  * Findings with a fix suggestion (``code_locations[].fix_before`` +
    ``fix_after``) are emitted as SARIF ``fixes`` so code-scanning can
    render a one-click suggested change.
  * Endpoint / target-only findings (typical of DAST) carry a SARIF
    ``logicalLocations`` entry so the finding keeps a meaningful anchor
    even without a source file + line.
  * When a repository context is supplied (repo URL / commit / branch),
    the run carries ``versionControlProvenance`` + ``automationDetails``
    so code-scanning can bind alerts to the scanned commit and branch.
  * Findings without safe locations still appear in the SARIF output,
    anchored to SECURITY.md and flagged via
    ``properties.synthetic_location`` rather than being dropped silently.
  * Coverage rides in the same document as non-failing results (``kind`` of
    ``pass`` / ``notApplicable`` / ``open``), and run completeness on
    ``run.invocations``. Consumers that only want alerts filter on
    ``kind == "fail"`` and are unaffected.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
import re
from pathlib import Path, PurePosixPath
from typing import Any, cast


logger = logging.getLogger(__name__)


SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
SARIF_VERSION = "2.1.0"
TOOL_NAME = "Strix"
TOOL_INFORMATION_URI = "https://strix.ai"

# Synthetic anchor for findings that have no safe code location. SARIF
# requires every result to carry at least one location, and GitHub
# code-scanning's UI handles locationless results unreliably. Anchoring
# to SECURITY.md keeps the result valid + visible while a
# ``properties.synthetic_location: true`` flag lets downstream tooling
# distinguish synthetic anchors from real source locations. Anchoring
# also lets the partialFingerprints + class-hash code path cover these
# findings instead of re-orphaning them on every run.
_SYNTHETIC_LOCATION_URI = "SECURITY.md"


# SARIF only has three result levels; Strix's five severities collapse here.
# Original label survives in ``result.properties.strix.severity``.
_SEVERITY_TO_LEVEL = {
    "critical": "error",
    "high": "error",
    "medium": "warning",
    "low": "note",
    "info": "note",
    "informational": "note",
}

# GitHub code-scanning reads ``rule.properties['security-severity']`` (a
# 0.0-10.0 string) to rank alerts. We prefer CVSS from the finding; absent
# that we fall back to a conservative label -> score map.
_SEVERITY_TO_SCORE = {
    "critical": "9.5",
    "high": "8.0",
    "medium": "5.5",
    "low": "3.0",
    "info": "1.0",
    "informational": "1.0",
}


# CWE → STRIDE-leg mapping for SARIF rule tagging. Each finding's rule gets one
# or more ``stride:<leg>`` tags (Spoofing / Tampering / Repudiation /
# Information disclosure / Denial of service / Elevation of privilege) so
# consumers — the GitHub code-scanning Security tab, ASPM dashboards, coverage
# reports — can group and filter findings by threat-model leg. SARIF results
# inherit their rule's tags via ``ruleId``, so tagging the rule is sufficient.
#
# Where a CWE plausibly spans multiple legs the dominant one is listed first.
# Unmapped / no-CWE findings fall back to ``_DEFAULT_STRIDE_LEGS`` so every
# finding carries at least one leg (no coverage gaps in downstream reports).
_CWE_TO_STRIDE: dict[str, tuple[str, ...]] = {
    # Spoofing — authentication / identity
    "287": ("S",),  # Improper Authentication
    "290": ("S",),  # Authentication Bypass by Spoofing
    "294": ("S",),  # Authentication Bypass by Capture-replay
    "306": ("S", "E"),  # Missing Authentication for Critical Function
    "345": ("S", "T"),  # Insufficient Verification of Data Authenticity
    "346": ("S",),  # Origin Validation Error
    "352": ("T", "S"),  # Cross-Site Request Forgery
    "384": ("S",),  # Session Fixation
    "521": ("S",),  # Weak Password Requirements
    "613": ("S",),  # Insufficient Session Expiration
    "640": ("S",),  # Weak Password Recovery Mechanism
    "259": ("S", "I"),  # Use of Hard-coded Password
    "798": ("S", "I"),  # Use of Hard-coded Credentials
    "1391": ("S",),  # Use of Weak Credentials
    # Tampering — integrity
    "20": ("T",),  # Improper Input Validation
    "73": ("T", "I"),  # External Control of File Name or Path
    "78": ("T", "E"),  # OS Command Injection
    "79": ("T", "I"),  # Cross-Site Scripting
    "89": ("T",),  # SQL Injection
    "91": ("T",),  # XML Injection
    "94": ("T", "E"),  # Code Injection
    "434": ("T",),  # Unrestricted File Upload
    "502": ("T", "E"),  # Deserialization of Untrusted Data
    "915": ("E", "T"),  # Improperly Controlled Modification (Mass Assignment)
    "918": ("T", "I"),  # Server-Side Request Forgery
    "1336": ("T", "E"),  # Server-Side Template Injection
    # Repudiation — audit / logging
    "117": ("R",),  # Improper Output Neutralization for Logs
    "223": ("R",),  # Omission of Security-relevant Information
    "778": ("R",),  # Insufficient Logging
    # Information disclosure — confidentiality
    "200": ("I",),  # Exposure of Sensitive Information
    "201": ("I",),  # Insertion of Sensitive Info into Sent Data
    "209": ("I",),  # Generation of Error Message Containing Sensitive Info
    "256": ("I",),  # Plaintext Storage of a Password
    "311": ("I",),  # Missing Encryption of Sensitive Data
    "319": ("I",),  # Cleartext Transmission of Sensitive Information
    "327": ("I",),  # Use of a Broken or Risky Cryptographic Algorithm
    "328": ("I",),  # Use of Weak Hash
    "522": ("I",),  # Insufficiently Protected Credentials
    "525": ("I",),  # Use of Web Browser Cache Containing Sensitive Info
    "532": ("I",),  # Insertion of Sensitive Information into Log File
    "538": ("I",),  # Insertion of Sensitive Info into Externally-Accessible File
    "598": ("I",),  # Use of GET Request Method With Sensitive Query Strings
    # Denial of service — availability
    "400": ("D",),  # Uncontrolled Resource Consumption
    "770": ("D",),  # Allocation of Resources Without Limits or Throttling
    "1333": ("D",),  # Inefficient Regular Expression Complexity (ReDoS)
    # Elevation of privilege — authorization
    "269": ("E",),  # Improper Privilege Management
    "284": ("E",),  # Improper Access Control
    "285": ("E",),  # Improper Authorization
    "639": ("E",),  # Authorization Bypass Through User-Controlled Key (IDOR/BOLA)
    "732": ("E",),  # Incorrect Permission Assignment for Critical Resource
    "862": ("E",),  # Missing Authorization
    "863": ("E",),  # Incorrect Authorization
    "1220": ("E",),  # Insufficient Granularity of Access Control
    # Multi-leg
    "22": ("T", "I"),  # Path Traversal — write/read arbitrary paths (T) + file disclosure (I)
    "611": ("I", "T"),  # XML External Entity (XXE)
}

# Default for unmapped / no-CWE findings: tampering + information-disclosure is
# the most-common shape for an unclassified bug (matches the fork's and
# strix-triage's DEFAULT_STRIDE_LEGS convention).
_DEFAULT_STRIDE_LEGS: tuple[str, ...] = ("T", "I")


def _stride_legs_for_cwe(cwe: str | None) -> tuple[str, ...]:
    """Map a CWE id (``CWE-306`` / ``306`` / ``cwe: 306``) to STRIDE legs.
    Returns ``_DEFAULT_STRIDE_LEGS`` for no-CWE / unrecognised input so every
    finding gets at least one leg tag."""
    if not cwe:
        return _DEFAULT_STRIDE_LEGS
    digits = "".join(c for c in str(cwe) if c.isdigit())
    if not digits:
        return _DEFAULT_STRIDE_LEGS
    return _CWE_TO_STRIDE.get(digits, _DEFAULT_STRIDE_LEGS)


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def build_sarif_report(
    vulnerability_reports: list[dict[str, Any]],
    *,
    tool_version: str | None = None,
    repository_context: dict[str, Any] | None = None,
    coverage: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Return a SARIF 2.1.0 document for findings.

    ``repository_context`` (optional) supplies VCS provenance for repo
    scans: ``repositoryUri``, ``repositoryFullName``, ``commitSha``,
    ``branch``, ``ref``. When present, the run carries
    ``versionControlProvenance`` + ``automationDetails`` so code-scanning
    can bind alerts to the scanned commit; it is omitted for URL / IP
    (DAST) targets that have no repository.

    ``coverage`` (optional) is the document from
    :func:`strix.report.coverage.build_coverage_document`: its cleared
    surfaces become non-failing results and its completeness caveats become
    invocation notifications.

    Findings without safe source locations are anchored synthetically
    to SECURITY.md and flagged via ``properties.synthetic_location``.
    They're still emitted as proper SARIF results so they (a) flow
    through code-scanning normally rather than being shunted into a
    run-properties summary the UI can't render, and (b) carry the
    partialFingerprints + class hash so cross-run dismissal stickiness
    works for them.
    """
    rules_by_id: dict[str, dict[str, Any]] = {}
    rule_index_by_id: dict[str, int] = {}
    results: list[dict[str, Any]] = []
    synthetic_location_count = 0
    dropped_unsafe_location_findings: list[dict[str, Any]] = []

    for report in vulnerability_reports:
        locations, is_synthetic, dropped_location_count = _build_locations(report)
        if is_synthetic:
            synthetic_location_count += 1

        if dropped_location_count:
            dropped_unsafe_location_findings.append(
                _dropped_location_summary(report, dropped_location_count)
            )

        rule_id = _rule_id(report)
        if rule_id not in rules_by_id:
            rule_index_by_id[rule_id] = len(rules_by_id)
            rules_by_id[rule_id] = _build_rule(rule_id, report)
        results.append(
            _build_result(
                rule_id,
                rule_index_by_id[rule_id],
                report,
                locations,
                is_synthetic=is_synthetic,
            )
        )

    if coverage:
        _append_coverage(coverage, rules_by_id, rule_index_by_id, results)

    driver: dict[str, Any] = {
        "name": TOOL_NAME,
        "informationUri": TOOL_INFORMATION_URI,
        "rules": list(rules_by_id.values()),
    }
    if tool_version:
        driver["version"] = tool_version

    run: dict[str, Any] = {
        "tool": {"driver": driver},
        "results": results,
    }

    if coverage:
        run["invocations"] = [_coverage_invocation(coverage)]

    run_properties: dict[str, Any] = {}
    if synthetic_location_count:
        # Surface the count for observability without duplicating the
        # findings themselves — they're already in `results[]` with
        # `properties.synthetic_location: true`. Having a top-level count
        # means CI logs / dashboards can bookkeep without parsing the
        # result list.
        run_properties["syntheticLocationCount"] = synthetic_location_count
    if dropped_unsafe_location_findings:
        run_properties["droppedUnsafeLocationCount"] = sum(
            finding["droppedLocationCount"] for finding in dropped_unsafe_location_findings
        )
        run_properties["droppedUnsafeLocationFindings"] = dropped_unsafe_location_findings
    if run_properties:
        run["properties"] = run_properties

    if repository_context:
        _apply_repository_context(run, repository_context)

    return {
        "version": SARIF_VERSION,
        "$schema": SARIF_SCHEMA,
        "runs": [run],
    }


def write_sarif_report(
    output_path: Path,
    vulnerability_reports: list[dict[str, Any]],
    *,
    tool_version: str | None = None,
    repository_context: dict[str, Any] | None = None,
    coverage: dict[str, Any] | None = None,
) -> None:
    """Write a SARIF report to disk, creating parent directories first.

    Writes to a sibling temp file and atomically replaces the target, so a
    crash or serialization error mid-write can never leave a truncated
    ``findings.sarif`` for CI to upload in place of the last complete snapshot.
    """
    output_path.parent.mkdir(parents=True, exist_ok=True)
    sarif = build_sarif_report(
        vulnerability_reports,
        tool_version=tool_version,
        repository_context=repository_context,
        coverage=coverage,
    )
    tmp_path = output_path.with_name(f"{output_path.name}.{os.getpid()}.tmp")
    try:
        with tmp_path.open("w", encoding="utf-8") as sarif_file:
            json.dump(sarif, sarif_file, ensure_ascii=False, indent=2)
            sarif_file.write("\n")
        tmp_path.replace(output_path)  # atomic on the same filesystem
    finally:
        tmp_path.unlink(missing_ok=True)


def write_sarif(
    run_dir: Path,
    reports: list[dict[str, Any]],
    *,
    tool_version: str | None = None,
    repository_context: dict[str, Any] | None = None,
    coverage: dict[str, Any] | None = None,
    filename: str = "findings.sarif",
) -> Path:
    """Write ``findings.sarif`` alongside existing outputs in ``run_dir``.

    Returns the output path. This is the ``ReportState`` entry point: SARIF
    writing must never break the CSV + markdown path, so the caller wraps
    it in try/except.
    """
    out = run_dir / filename
    write_sarif_report(
        out,
        reports,
        tool_version=tool_version,
        repository_context=repository_context,
        coverage=coverage,
    )
    logger.info(
        "Wrote SARIF 2.1.0 report: %s (%d results)",
        out,
        len(reports),
    )
    return out


# ``build_sarif_document`` is a convenience alias for callers that prefer a
# name mirroring ``write_sarif_report``.
def build_sarif_document(
    reports: list[dict[str, Any]],
    *,
    tool_version: str | None = None,
    repository_context: dict[str, Any] | None = None,
) -> dict[str, Any]:
    return build_sarif_report(
        reports,
        tool_version=tool_version,
        repository_context=repository_context,
    )


# ---------------------------------------------------------------------------
# Repository provenance
# ---------------------------------------------------------------------------


def _apply_repository_context(run: dict[str, Any], context: dict[str, Any]) -> None:
    """Attach VCS provenance to a run for code-scanning alert binding.

    ``automationDetails.id`` categorises the run (``strix/<owner>/<repo>``)
    so multiple Strix runs against the same repo reconcile rather than pile
    up. ``versionControlProvenance`` records the exact repo + commit + branch
    the findings came from. Both are omitted when the corresponding context
    fields are absent (e.g. DAST-only scans).
    """
    full_name = _string_value(context.get("repositoryFullName"))
    uri = _string_value(context.get("repositoryUri"))
    commit = _string_value(context.get("commitSha"))
    branch = _string_value(context.get("branch"))
    ref = _string_value(context.get("ref"))

    if full_name:
        run["automationDetails"] = {"id": f"strix/{full_name}"}

    if uri:
        provenance: dict[str, Any] = {"repositoryUri": uri}
        if commit:
            provenance["revisionId"] = commit
        if branch:
            provenance["branch"] = branch
        run["versionControlProvenance"] = [provenance]

    properties = run.setdefault("properties", {})
    if full_name:
        properties["repository"] = full_name
    if ref:
        properties["ref"] = ref
    if commit:
        properties["commit_sha"] = commit
    if not properties:
        run.pop("properties", None)


# ---------------------------------------------------------------------------
# Rule + result builders
# ---------------------------------------------------------------------------


def _build_rule(rule_id: str, report: dict[str, Any]) -> dict[str, Any]:
    """Build a SARIF rule descriptor from a Strix finding."""
    title = _string_value(report.get("title")) or rule_id
    full_description = _string_value(report.get("description")) or title
    help_text = _help_text(report, full_description)

    rule: dict[str, Any] = {
        "id": rule_id,
        "name": _rule_name(rule_id, title),
        "shortDescription": {"text": title},
        "fullDescription": {"text": full_description},
        "defaultConfiguration": {"level": _sarif_level(report.get("severity"))},
        "help": {"text": help_text, "markdown": help_text},
    }

    properties: dict[str, Any] = {
        "security-severity": _security_severity(report),
    }
    tags = _rule_tags(rule_id, report)
    if tags:
        properties["tags"] = tags
    rule["properties"] = properties

    help_uri = _help_uri_for(rule_id)
    if help_uri:
        rule["helpUri"] = help_uri

    return rule


def _build_result(
    rule_id: str,
    rule_index: int,
    report: dict[str, Any],
    locations: list[dict[str, Any]],
    *,
    is_synthetic: bool = False,
) -> dict[str, Any]:
    """Build one SARIF result using validated locations.

    ``is_synthetic`` flags results whose location is the SECURITY.md
    anchor rather than a real code location — surfaces as
    ``properties.synthetic_location: true`` so reviewers and downstream
    tooling can distinguish anchored-locationless findings from
    source-linked ones.
    """
    title = _string_value(report.get("title")) or rule_id
    description = _string_value(report.get("description"))
    message_text = f"{title}\n\n{description}" if description else title

    result: dict[str, Any] = {
        "ruleId": rule_id,
        "ruleIndex": rule_index,
        "level": _sarif_level(report.get("severity")),
        "message": {"text": message_text},
    }
    if locations:
        result["locations"] = locations

    fixes = _build_fixes(report)
    if fixes:
        result["fixes"] = fixes

    # Code-scanning auto-resolution + dismissal-stickiness key on
    # partialFingerprints. Computed from the deterministic primitives
    # this report already carries (CWE, primary code location, route
    # tuple) — NOT from the LLM-authored title or message body, which
    # vary cosmetically across runs of the same finding.
    fp = _primary_fingerprint(rule_id, report, locations, is_synthetic=is_synthetic)
    if fp:
        result["partialFingerprints"] = {"primaryLocationLineHash": fp}
    # File-independent class fingerprint as a sibling property: lets
    # downstream tooling carry "won't fix" / "false positive"
    # determinations across file rename refactors where the primary
    # fingerprint legitimately shifts but the underlying class is
    # unchanged.
    class_fp = _class_fingerprint(rule_id, report)
    result["properties"] = _result_properties(report, class_fp, is_synthetic=is_synthetic)
    return result


def _result_properties(
    report: dict[str, Any],
    class_fingerprint: str | None = None,
    *,
    is_synthetic: bool = False,
) -> dict[str, Any]:
    """Strix-specific metadata for downstream consumers.

    The top-level ``security-severity`` matches GitHub code-scanning's
    expected property. Strix-specific fields are namespaced under
    ``strix`` so generic SARIF consumers don't see them by default.
    """
    properties: dict[str, Any] = {
        "security-severity": _security_severity(report),
    }
    if class_fingerprint:
        # Surfaced at top level so cross-rename dismissal tooling can
        # filter alerts by it without parsing the nested strix.* tree.
        properties["strix_vuln_class_hash"] = class_fingerprint
    if is_synthetic:
        # Top-level so reviewers + downstream automation can filter
        # synthetic-anchored alerts without parsing the nested strix.*
        # tree.
        properties["synthetic_location"] = True

    strix: dict[str, Any] = {}
    for key in (
        "id",
        "severity",
        "cvss",
        "timestamp",
        "target",
        "endpoint",
        "method",
        "cve",
        "cwe",
        "impact",
        "technical_analysis",
        "remediation_steps",
        "counterevidence",
        "confidence",
        "confidence_rationale",
        "severity_change_conditions",
        "fix_verification",
    ):
        value = report.get(key)
        if value not in (None, ""):
            strix[key] = value

    dependency_metadata = report.get("dependency_metadata")
    if isinstance(dependency_metadata, dict) and dependency_metadata:
        strix["dependency_metadata"] = dependency_metadata

    # SARIF is written for external upload (code-scanning / ASPM), so it must
    # NOT carry the weaponized exploit payload — that stays a local run
    # artifact (vulnerabilities.json / the finding MD). We surface the PoC
    # *description* (triage context) and a boolean flag that a script exists,
    # so consumers know to look at the local artifact, but never the script
    # body itself.
    poc_description = _string_value(report.get("poc_description"))
    poc_script = _string_value(report.get("poc_script_code"))
    if poc_description or poc_script:
        poc: dict[str, Any] = {}
        if poc_description:
            poc["description"] = poc_description
        if poc_script:
            poc["script_available"] = True
        strix["poc"] = poc

    if strix:
        properties["strix"] = strix

    return properties


def _build_fixes(report: dict[str, Any]) -> list[dict[str, Any]] | None:
    """Build SARIF ``fixes`` from a finding's code-location fix pairs.

    Strix findings carry the suggested change inline on each code
    location as ``fix_before`` + ``fix_after``. We map every location
    that has both (and a safe repo-relative URI + start line) into a
    SARIF ``artifactChange``, replacing the finding's region with the
    fixed text. Returns None when no location carries a usable fix pair.
    """
    raw_locations = report.get("code_locations")
    if not isinstance(raw_locations, list):
        return None

    artifact_changes: list[dict[str, Any]] = []
    for location in raw_locations:
        if not isinstance(location, dict):
            continue
        file_path = _string_value(location.get("file"))
        fix_before = _string_value(location.get("fix_before"))
        fix_after = _string_value(location.get("fix_after"))
        start_line = location.get("start_line")
        if not (file_path and fix_before and fix_after):
            continue
        if type(start_line) is not int or start_line < 1:
            continue
        uri = _sarif_uri(file_path)
        if uri is None:
            continue

        deleted_region: dict[str, Any] = {"startLine": start_line}
        end_line = location.get("end_line")
        if type(end_line) is int and end_line >= start_line:
            deleted_region["endLine"] = end_line

        artifact_changes.append(
            {
                "artifactLocation": {"uri": uri},
                "replacements": [
                    {
                        "deletedRegion": deleted_region,
                        "insertedContent": {"text": fix_after},
                    }
                ],
            }
        )

    if not artifact_changes:
        return None

    fix: dict[str, Any] = {"artifactChanges": artifact_changes}
    remediation = _string_value(report.get("remediation_steps"))
    if remediation:
        fix["description"] = {"text": remediation, "markdown": remediation}
    return [fix]


# ---------------------------------------------------------------------------
# Coverage
# ---------------------------------------------------------------------------

_COVERAGE_RULE_PREFIX = "strix-coverage"

# ``reported`` is absent on purpose: those surfaces are already in ``results``
# as ``fail`` findings.
_OUTCOME_TO_KIND = {
    "no_issue_found": "pass",
    "ruled_out": "pass",
    "not_applicable": "notApplicable",
    "needs_follow_up": "open",
}


def _coverage_rule_id(risk_area: str) -> str:
    slug = _slugify(risk_area) or "unspecified"
    return f"{_COVERAGE_RULE_PREFIX}/{slug}"


def _build_coverage_rule(rule_id: str, risk_area: str) -> dict[str, Any]:
    description = f"Coverage of {risk_area} across the assessed attack surface."
    return {
        "id": rule_id,
        "name": _rule_name(rule_id, risk_area),
        "shortDescription": {"text": f"Coverage: {risk_area}"},
        "fullDescription": {"text": description},
        "defaultConfiguration": {"level": "none"},
        "help": {"text": description, "markdown": description},
        "properties": {"tags": ["coverage"]},
    }


def _build_coverage_result(
    rule_id: str,
    rule_index: int,
    kind: str,
    entry: dict[str, Any],
) -> dict[str, Any]:
    surface = _string_value(entry.get("surface")) or "unspecified surface"
    risk_area = _string_value(entry.get("risk_area")) or "unspecified risk"
    evidence = _string_value(entry.get("evidence"))
    label = _string_value(entry.get("outcome_label")) or str(entry.get("outcome", ""))

    message = f"{risk_area} — {label}: {surface}"
    if evidence:
        message = f"{message}\n\n{evidence}"

    result: dict[str, Any] = {
        "ruleId": rule_id,
        "ruleIndex": rule_index,
        "kind": kind,
        # SARIF requires ``level: none`` for any result whose kind is not ``fail``.
        "level": "none",
        "message": {"text": message},
        "locations": [{"logicalLocations": [{"fullyQualifiedName": surface}]}],
        "properties": {
            "strix": {
                "coverage_outcome": entry.get("outcome", ""),
                "risk_area": risk_area,
                "surface": surface,
                "recorded_by": entry.get("recorded_by", ""),
                "source": entry.get("source", "agent_reported"),
            }
        },
    }
    return result


def _append_coverage(
    coverage: dict[str, Any],
    rules_by_id: dict[str, dict[str, Any]],
    rule_index_by_id: dict[str, int],
    results: list[dict[str, Any]],
) -> None:
    entries = coverage.get("entries")
    if not isinstance(entries, list):
        return
    for entry in entries:
        if not isinstance(entry, dict):
            continue
        kind = _OUTCOME_TO_KIND.get(str(entry.get("outcome", "")))
        if kind is None:
            continue
        rule_id = _coverage_rule_id(str(entry.get("risk_area", "")))
        if rule_id not in rules_by_id:
            rule_index_by_id[rule_id] = len(rules_by_id)
            rules_by_id[rule_id] = _build_coverage_rule(
                rule_id, _string_value(entry.get("risk_area")) or "unspecified risk"
            )
        results.append(_build_coverage_result(rule_id, rule_index_by_id[rule_id], kind, entry))


def _coverage_invocation(coverage: dict[str, Any]) -> dict[str, Any]:
    """``executionSuccessful: false`` stops a truncated run reading as a clean one."""
    completeness = coverage.get("completeness")
    completeness = completeness if isinstance(completeness, dict) else {}
    caveats = completeness.get("caveats")
    caveats = caveats if isinstance(caveats, list) else []

    invocation: dict[str, Any] = {"executionSuccessful": bool(completeness.get("complete", True))}
    if caveats:
        invocation["toolExecutionNotifications"] = [
            {"level": "warning", "message": {"text": str(caveat)}} for caveat in caveats
        ]
    return invocation


# ---------------------------------------------------------------------------
# Location handling
# ---------------------------------------------------------------------------


def _synthetic_location() -> dict[str, Any]:
    """Synthetic anchor for findings with no safe code location.

    SARIF requires every result to carry at least one location, and
    code-scanning's UI handles locationless results unreliably.
    Anchoring to SECURITY.md gives the result a valid + visible
    location; the result's ``properties.synthetic_location: true``
    flag lets reviewers + tooling distinguish synthetic from real.
    """
    return {
        "physicalLocation": {
            "artifactLocation": {"uri": _SYNTHETIC_LOCATION_URI},
        }
    }


def _build_locations(report: dict[str, Any]) -> tuple[list[dict[str, Any]], bool, int]:
    """Return ``(locations, is_synthetic, dropped_count)`` for a finding.

    Physical locations come from validated ``code_locations``. When none
    are safe, the result is anchored to SECURITY.md (``is_synthetic``).
    An ``endpoint`` (typical of DAST findings) is added as a
    ``logicalLocations`` entry; a locationless, endpoint-less finding
    gets a ``resource`` logical location carrying the target so the
    finding keeps a human-meaningful anchor.
    """
    physical, dropped_location_count = _build_physical_locations(report.get("code_locations"))
    is_synthetic = not physical
    locations: list[dict[str, Any]] = list(physical) if physical else [_synthetic_location()]

    endpoint = _string_value(report.get("endpoint"))
    if endpoint:
        locations.append(
            {"logicalLocations": [{"fullyQualifiedName": endpoint, "kind": "endpoint"}]}
        )
    elif is_synthetic:
        resource = _string_value(report.get("target")) or _string_value(report.get("title"))
        if resource:
            locations.append(
                {"logicalLocations": [{"fullyQualifiedName": resource, "kind": "resource"}]}
            )

    return locations, is_synthetic, dropped_location_count


def _build_physical_locations(raw_locations: Any) -> tuple[list[dict[str, Any]], int]:
    """Return SARIF physical locations and a count of dropped unsafe locations."""
    if not isinstance(raw_locations, list):
        return [], 0

    locations: list[dict[str, Any]] = []
    dropped_location_count = 0
    for location in raw_locations:
        if not isinstance(location, dict):
            dropped_location_count += 1
            continue

        file_path = _string_value(location.get("file"))
        start_line = location.get("start_line")
        end_line = location.get("end_line")
        if not file_path or type(start_line) is not int or start_line < 1:
            dropped_location_count += 1
            continue
        uri = _sarif_uri(file_path)
        if uri is None:
            dropped_location_count += 1
            continue

        region: dict[str, Any] = {"startLine": start_line}
        if type(end_line) is int and end_line >= start_line:
            region["endLine"] = end_line

        snippet = _string_value(location.get("snippet"))
        if snippet:
            region["snippet"] = {"text": snippet}

        physical_location: dict[str, Any] = {
            "artifactLocation": {"uri": uri},
            "region": region,
        }
        entry: dict[str, Any] = {"physicalLocation": physical_location}

        label = _string_value(location.get("label"))
        if label:
            entry["message"] = {"text": label}

        locations.append(entry)

    return locations, dropped_location_count


def _sarif_uri(file_path: str) -> str | None:
    """Return a safe repo-relative SARIF URI, or None for unsafe paths."""
    uri = PurePosixPath(file_path.replace("\\", "/")).as_posix()
    parts = PurePosixPath(uri).parts
    if not uri or uri.startswith("/") or not parts:
        return None
    if ":" in parts[0] or any(part == ".." for part in parts):
        return None
    return uri


# ---------------------------------------------------------------------------
# Rule ID resolution + CWE normalisation
# ---------------------------------------------------------------------------


def _rule_id(report: dict[str, Any]) -> str:
    """Choose a stable SARIF rule id, preferring CWE → CVE → finding-id → slug.

    CWE values are normalised from Strix output variants (``CWE-306``,
    ``cwe: 306``, ``306``) to the canonical ``CWE-NNN`` form. Without
    normalisation, the same weakness across runs dedups to separate rules.
    """
    cwe = _string_value(report.get("cwe"))
    if cwe:
        normalised = _normalise_cwe(cwe)
        if normalised:
            return normalised

    cve = _string_value(report.get("cve"))
    if cve:
        return cve

    finding_id = _string_value(report.get("id"))
    if finding_id:
        return finding_id

    title = _string_value(report.get("title")) or "strix-finding"
    return _slugify(title)


def _normalise_cwe(value: str) -> str | None:
    """``CWE-306``, ``cwe:306``, ``306`` → ``CWE-306``."""
    digits = "".join(c for c in value if c.isdigit())
    if not digits:
        return None
    return f"CWE-{digits}"


# Vulnerability-class keywords for the file-independent class
# fingerprint. Order matters — first match wins, so precise terms
# come before fuzzy ones (e.g. "broken access control" before
# "access control"). Keep this list closed and curated; a future
# maintainer adding sloppy entries could collapse distinct findings
# to the same class hash.
_VULN_CLASS_KEYWORDS = (
    "missing authentication",
    "missing authorization",
    "broken access control",
    "incorrect authorization",
    "default credentials",
    "hardcoded credentials",
    "hardcoded secret",
    "hardcoded password",
    "default admin",
    "default password",
    "session fixation",
    "open redirect",
    "path traversal",
    "directory traversal",
    "command injection",
    "sql injection",
    "code injection",
    "template injection",
    "xpath injection",
    "ldap injection",
    "log injection",
    "header injection",
    "csv injection",
    "prompt injection",
    "deserialization",
    "ssrf",
    "xss",
    "csrf",
    "xxe",
    "race condition",
    "toctou",
    "information disclosure",
    "insecure direct object reference",
    "idor",
    "bola",
    "bfla",
    "cross-tenant",
    "cross-project",
    "tenant bypass",
    "auth bypass",
    "rate limiting",
    "rate limit",
    "weak cryptography",
    "weak hash",
    "weak random",
    "insecure random",
    "tls verification",
    "certificate verification",
    "denial of service",
    "regex denial of service",
    "redos",
    "supply chain",
)


def _primary_fingerprint(
    rule_id: str,
    report: dict[str, Any],
    locations: list[dict[str, Any]],
    *,
    is_synthetic: bool = False,
) -> str | None:
    """Deterministic per-finding fingerprint for SARIF auto-resolution.

    Computed from primitives that don't depend on LLM prose stability:

      - rule_id (already CWE-normalised by ``_rule_id``)
      - first SARIF location's URI + startLine — these come from
        Strix's ``code_locations[].file`` and ``start_line`` which
        are sourced from the actual finding evidence, not synthesized
      - HTTP method + endpoint when present (BOLA/IDOR/missing-authz
        findings carry these explicitly in the report dict)

    Synthetic-anchored findings (``is_synthetic=True``) all share
    uri="SECURITY.md" and have no real start_line. Hashing by
    (rule_id, "SECURITY.md") alone would collapse every locationless
    finding of the same CWE into a single alert. To distinguish them,
    the synthetic path adds the class keyword extracted from the title
    (same logic ``_class_fingerprint`` uses). The class keyword
    catalogue is closed and stable, so cross-run identity holds — same
    vulnerability class on the same rule_id always lands on the same
    hash, and two different classes on the same rule_id don't collide.

    Returns None when no anchor is available AND not synthetic.
    """
    primary_physical = _first_physical_location(locations)
    uri = ""
    start_line: int | None = None
    if primary_physical:
        uri = (primary_physical.get("artifactLocation") or {}).get("uri", "") or ""
        region = primary_physical.get("region") or {}
        sl = region.get("startLine")
        if isinstance(sl, int) and sl >= 1:
            start_line = sl

    method = _string_value(report.get("method")) or ""
    endpoint = _string_value(report.get("endpoint")) or ""
    route = f"{method.upper()} {endpoint}".strip() if (method or endpoint) else ""

    if not uri and not route:
        return None

    parts = [f"rule:{rule_id}"]
    if uri:
        parts.append(f"uri:{uri}")
        if start_line is not None:
            # startLine in fingerprint is debatable: line shifts in
            # surrounding code re-fingerprint. The alternative — drop
            # line — collides multiple findings per file. We include
            # line because Strix code_locations carry the SINK line,
            # which moves only when the vulnerable code itself moves;
            # lines surfacing from cosmetic edits in unrelated parts
            # of the file don't shift it.
            parts.append(f"line:{start_line}")
    if route:
        parts.append(f"route:{route}")

    if is_synthetic:
        # Augment with class keyword so locationless findings of
        # different vuln classes don't collide. rule_id is already in
        # the composite so we don't also need to stamp the CWE.
        title = _string_value(report.get("title")) or ""
        if title:
            parts.append(f"synth_class:{_class_keyword(title)}")

    composite = "|".join(parts)
    return hashlib.sha256(composite.encode("utf-8")).hexdigest()


def _first_physical_location(locations: list[dict[str, Any]]) -> dict[str, Any] | None:
    """Return the first location's physicalLocation payload, if any."""
    for location in locations:
        physical = location.get("physicalLocation")
        if physical:
            return cast("dict[str, Any]", physical)
    return None


def _class_fingerprint(rule_id: str, report: dict[str, Any]) -> str | None:
    """File-independent fingerprint for cross-rename dismissal carryover.

    Lets downstream tooling apply prior dismissal determinations to a
    new alert that has the same vulnerability class but a different
    primary fingerprint (typical case: file rename, or a fix that moves
    the vulnerable code to a new module).

    Composite of (rule_id, vuln-class keyword extracted from title).
    Title is LLM-authored so it's stochastic at the prose level, but
    the class keyword extraction picks up the discrete vulnerability
    category, which is much more stable than the full title.

    Falls back to the first 5 lowercased words of the title when no
    curated keyword matches. Acceptable as a fallback because the
    class fingerprint is a tiebreaker, not a primary reconciliation key.
    """
    title = _string_value(report.get("title")) or ""
    keyword = _class_keyword(title) if title else ""
    if not keyword:
        return None
    composite = f"rule:{rule_id}|class:{keyword}"
    return hashlib.sha256(composite.encode("utf-8")).hexdigest()


def _class_keyword(title: str) -> str:
    """Pick the first matching curated keyword in ``title``, or fall
    back to the first 5 lowercased alpha-numeric words.

    Shared between ``_class_fingerprint`` (file-rename carryover) and
    ``_primary_fingerprint`` (synthetic-location distinguisher) so the
    two fingerprints stay aligned on what counts as "the same class".
    Returns empty string when title is empty or whitespace-only.
    """
    if not title:
        return ""
    lower = title.lower()
    for kw in _VULN_CLASS_KEYWORDS:
        if kw in lower:
            return kw
    words = re.findall(r"[a-z0-9]+", lower)[:5]
    return " ".join(words)


def _rule_name(rule_id: str, title: str) -> str:
    """SARIF rule.name must be a free-form string; prefer the finding title
    where available, fall back to a snake_case'd form of the rule id."""
    return title or rule_id.replace("-", "_")


def _rule_tags(rule_id: str, report: dict[str, Any]) -> list[str]:
    tags: list[str] = ["security"]
    if rule_id.startswith("CWE-"):
        tags.append(rule_id)
    cve = _string_value(report.get("cve"))
    if cve and cve not in tags:
        tags.append(cve)
    # STRIDE-leg tags from the CWE → STRIDE mapping. Always at least one (the
    # default legs for unmapped/no-CWE findings) so downstream threat-model
    # coverage reports have no gaps. Emitted as ``stride:S``, ``stride:T``, ...
    for leg in _stride_legs_for_cwe(report.get("cwe")):
        tag = f"stride:{leg}"
        if tag not in tags:
            tags.append(tag)
    return tags


def _help_uri_for(rule_id: str) -> str | None:
    if rule_id.startswith("CWE-"):
        return f"https://cwe.mitre.org/data/definitions/{rule_id.removeprefix('CWE-')}.html"
    return None


# ---------------------------------------------------------------------------
# Severity + help text
# ---------------------------------------------------------------------------


def _sarif_level(severity: Any) -> str:
    """Map Strix severity labels to SARIF result levels."""
    normalised = (_string_value(severity) or "").lower()
    return _SEVERITY_TO_LEVEL.get(normalised, "note")


def _security_severity(report: dict[str, Any]) -> str:
    """GitHub-compatible ``security-severity`` string in 0.0-10.0.

    Uses CVSS when present, otherwise falls back to the severity label.
    """
    cvss = report.get("cvss")
    if cvss is not None:
        try:
            return f"{float(cvss):.1f}"
        except (TypeError, ValueError):
            pass
    normalised = (_string_value(report.get("severity")) or "info").lower()
    return _SEVERITY_TO_SCORE.get(normalised, "1.0")


def _help_text(report: dict[str, Any], fallback: str) -> str:
    """Assemble SARIF help text from finding details and remediation."""
    sections = [
        _string_value(report.get("description")),
        _string_value(report.get("impact")),
        _string_value(report.get("remediation_steps")),
    ]
    help_text = "\n\n".join(section for section in sections if section)
    return help_text or fallback


# ---------------------------------------------------------------------------
# Summaries for unsafe-location findings
# ---------------------------------------------------------------------------


def _dropped_location_summary(
    report: dict[str, Any],
    dropped_location_count: int,
) -> dict[str, Any]:
    """Summarize unsafe locations dropped from a partially emitted finding."""
    summary: dict[str, Any] = {"droppedLocationCount": dropped_location_count}
    for key in ("id", "title"):
        value = report.get(key)
        if value not in (None, ""):
            summary[key] = value
    return summary


# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------


def _string_value(value: Any) -> str | None:
    """Return a stripped non-empty string value, or None."""
    if isinstance(value, str):
        stripped = value.strip()
        return stripped or None
    return None


def _slugify(value: str) -> str:
    """Convert arbitrary finding text into a stable lowercase slug."""
    chars = [char.lower() if char.isalnum() else "-" for char in value]
    slug = "-".join(part for part in "".join(chars).split("-") if part)
    return slug or "strix-finding"
