#!/usr/bin/env python3
"""
Donjon Platform - Report Quality Pass

Pure, dependency-free helpers that turn raw scanner findings into output a CISO
would forward: consistent/defensible severities, de-duplicated findings, scanner
noise removed, actionable remediation on every item, and an executive summary.

Design notes
------------
* These functions are SAFE for the reporting layer only. They never change what
  the scanners *detect* — they normalize and present the findings the scanners
  already produced. Noise filtering targets scanner *status/banner* lines and
  connection-failure pseudo-findings (e.g. nikto "[FAIL] Unable to connect"),
  not security findings.
* Everything operates on plain finding dicts and returns plain dicts, so it can
  be called from any report generator or exporter without a DB or server.

Canonical severity order (most → least severe):
    CRITICAL > HIGH > MEDIUM > LOW > INFO
"""
from __future__ import annotations

import re
from typing import Any, Dict, Iterable, List, Optional

# ---------------------------------------------------------------------------
# Severity normalization
# ---------------------------------------------------------------------------

SEVERITY_ORDER: List[str] = ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]
_SEVERITY_RANK: Dict[str, int] = {s: i for i, s in enumerate(SEVERITY_ORDER)}

# String aliases mapped onto the canonical set. Covers SARIF levels, syslog-style
# words, and common scanner vocabularies.
_SEVERITY_ALIASES: Dict[str, str] = {
    "CRITICAL": "CRITICAL",
    "CRIT": "CRITICAL",
    "HIGH": "HIGH",
    "ERROR": "HIGH",
    "MEDIUM": "MEDIUM",
    "MED": "MEDIUM",
    "MODERATE": "MEDIUM",
    "WARNING": "MEDIUM",
    "WARN": "MEDIUM",
    "LOW": "LOW",
    "MINOR": "LOW",
    "NOTE": "LOW",
    "INFO": "INFO",
    "INFORMATIONAL": "INFO",
    "INFORMATION": "INFO",
    "NONE": "INFO",
    "NA": "INFO",
    "N/A": "INFO",
}


def normalize_severity(value: Any) -> str:
    """Map any scanner severity (str, alias, or numeric CVSS-style score) to the
    canonical set ``CRITICAL|HIGH|MEDIUM|LOW|INFO``.

    Unknown / empty values default to ``INFO`` (the safe, non-alarming choice).
    Numeric values are bucketed using the standard CVSS v3 severity bands.
    """
    if value is None:
        return "INFO"

    # Numeric (CVSS-style) input.
    if isinstance(value, (int, float)):
        return _bucket_numeric(float(value))

    text = str(value).strip()
    if not text:
        return "INFO"

    # Numeric string?
    try:
        return _bucket_numeric(float(text))
    except ValueError:
        pass

    return _SEVERITY_ALIASES.get(text.upper(), "INFO")


def _bucket_numeric(score: float) -> str:
    """CVSS v3 qualitative bands. 0.0 -> INFO so 'no score' never inflates."""
    if score >= 9.0:
        return "CRITICAL"
    if score >= 7.0:
        return "HIGH"
    if score >= 4.0:
        return "MEDIUM"
    if score > 0.0:
        return "LOW"
    return "INFO"


def severity_rank(severity: Any) -> int:
    """Sort key: lower rank == more severe (CRITICAL=0 ... INFO=4)."""
    return _SEVERITY_RANK[normalize_severity(severity)]


def _max_severity(a: Any, b: Any) -> str:
    """Return the more severe of two severities."""
    na, nb = normalize_severity(a), normalize_severity(b)
    return na if _SEVERITY_RANK[na] <= _SEVERITY_RANK[nb] else nb


# ---------------------------------------------------------------------------
# Noise filtering — scanner status/banner lines are NOT findings
# ---------------------------------------------------------------------------

# Connection-failure pseudo-findings (e.g. nikto probing closed ports).
_NOISE_PATTERNS: List[re.Pattern] = [
    re.compile(r"^\[FAIL\]\s+Unable to connect", re.IGNORECASE),
    re.compile(r"^Unable to connect to", re.IGNORECASE),
    re.compile(r"^No CGI Directories found", re.IGNORECASE),
    re.compile(r"^Scan terminated:", re.IGNORECASE),
    re.compile(r"^\d+\s+host\(s\)\s+tested", re.IGNORECASE),
    re.compile(r"^Platform:\s", re.IGNORECASE),
    re.compile(r"^Server:\s", re.IGNORECASE),
    re.compile(r"^Target (IP|Hostname|Port):", re.IGNORECASE),
    re.compile(r"^Start Time:", re.IGNORECASE),
    re.compile(r"^End Time:", re.IGNORECASE),
    re.compile(r"^Nikto v\d", re.IGNORECASE),
    re.compile(r"^\+?\s*\d+\s+error\(s\)", re.IGNORECASE),
    re.compile(r"^Root page\b.*redirects to", re.IGNORECASE),
]


def is_noise(finding: Dict[str, Any]) -> bool:
    """True if a finding is a scanner status/banner/connection-failure line
    rather than a real security finding.

    Conservative by design: only matches well-known scanner-output boilerplate.
    A finding with a CVE, a non-zero CVSS score, or an elevated severity is
    never treated as noise.
    """
    sev = normalize_severity(finding.get("severity"))
    if sev in ("CRITICAL", "HIGH", "MEDIUM"):
        return False
    if finding.get("cve_ids"):
        return False
    try:
        if float(finding.get("cvss_score") or 0) > 0:
            return False
    except (TypeError, ValueError):
        pass

    title = str(finding.get("title", "")).strip()
    if not title:
        return True  # an untitled INFO/LOW item carries no value to a reader
    return any(pat.search(title) for pat in _NOISE_PATTERNS)


# ---------------------------------------------------------------------------
# Default remediation text by finding_type
# ---------------------------------------------------------------------------

_DEFAULT_REMEDIATION: Dict[str, str] = {
    "open_port": (
        "Confirm this service is required. Restrict access with host/network "
        "firewall rules, bind to internal interfaces only, and document the "
        "service in your asset inventory."
    ),
    "insecure_configuration": (
        "Harden the service configuration: add the missing security headers "
        "(e.g. Content-Security-Policy, X-Content-Type-Options, "
        "Strict-Transport-Security), set secure/HttpOnly cookie flags, and "
        "follow the vendor hardening guide."
    ),
    "information_disclosure": (
        "Remove or block public access to the disclosed resource. Exposed VCS "
        "directories (.git), backup files, and directory listings can leak "
        "source code, credentials, and internal paths — deny them at the web "
        "server and rotate any leaked secrets."
    ),
    "missing_patch": (
        "Apply the latest vendor security patch for the affected component and "
        "establish a recurring patch-management cadence."
    ),
    "sql_injection": (
        "Use parameterized queries / prepared statements, validate and "
        "allow-list all input, and apply least-privilege database accounts."
    ),
    "xss": (
        "Context-aware output encoding, input validation, and a strict "
        "Content-Security-Policy to mitigate cross-site scripting."
    ),
    "authentication_weakness": (
        "Enforce strong, unique credentials and multi-factor authentication; "
        "disable default accounts and lock out repeated failed logins."
    ),
    "expired_certificate": (
        "Renew and deploy a valid certificate from a trusted CA and automate "
        "renewal to prevent recurrence."
    ),
    "compliance_gap": (
        "Collect and map evidence to satisfy this control, then attest its "
        "status. Track the gap to closure in your remediation plan."
    ),
    "web_vulnerability": (
        "Review the affected endpoint, remove unnecessary exposure, and apply "
        "the relevant web-hardening control for this issue."
    ),
}

_GENERIC_REMEDIATION = (
    "Review this finding against your security baseline, remediate the "
    "underlying weakness, and re-scan to confirm closure."
)


def ensure_remediation(finding: Dict[str, Any]) -> Dict[str, Any]:
    """Return a copy of *finding* guaranteed to carry non-empty remediation text.

    Existing, meaningful remediation is preserved. If absent or blank, a
    type-specific default is supplied (falling back to a generic action).
    """
    out = dict(finding)
    existing = str(out.get("remediation") or "").strip()
    if existing:
        out["remediation"] = existing
        return out
    ftype = str(out.get("finding_type") or "").strip().lower()
    out["remediation"] = _DEFAULT_REMEDIATION.get(ftype, _GENERIC_REMEDIATION)
    out["remediation_source"] = "default"
    return out


# ---------------------------------------------------------------------------
# De-duplication (intra-report)
# ---------------------------------------------------------------------------

def _dedupe_key(finding: Dict[str, Any]) -> tuple:
    """Stable identity key for a finding within a single report.

    Keyed on (normalized title, affected asset, finding_type). This collapses
    the same issue reported by multiple scanners while keeping genuinely
    distinct findings apart.
    """
    title = str(finding.get("title", "")).strip().lower()
    asset = str(
        finding.get("affected_asset")
        or finding.get("host")
        or finding.get("target")
        or ""
    ).strip().lower()
    ftype = str(finding.get("finding_type") or "").strip().lower()
    return (title, asset, ftype)


def _scanner_of(finding: Dict[str, Any]) -> Optional[str]:
    return finding.get("scanner") or finding.get("scanner_name") or finding.get("tool")


def dedupe_findings(findings: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """Collapse duplicate findings, merging scanner sources and keeping the
    highest severity. Order of first appearance is preserved.

    Each surviving finding gains:
      * ``sources`` — sorted list of scanners that reported it
      * ``duplicate_count`` — how many raw findings collapsed into this one
      * ``severity`` — normalized, escalated to the most-severe observed
    """
    merged: "Dict[tuple, Dict[str, Any]]" = {}
    order: List[tuple] = []

    for finding in findings:
        key = _dedupe_key(finding)
        scanner = _scanner_of(finding)
        if key not in merged:
            rec = dict(finding)
            rec["severity"] = normalize_severity(finding.get("severity"))
            rec["sources"] = [scanner] if scanner else []
            rec["duplicate_count"] = 1
            merged[key] = rec
            order.append(key)
        else:
            rec = merged[key]
            rec["severity"] = _max_severity(rec.get("severity"), finding.get("severity"))
            if scanner and scanner not in rec["sources"]:
                rec["sources"].append(scanner)
            rec["duplicate_count"] += 1

    result = []
    for key in order:
        rec = merged[key]
        rec["sources"] = sorted(rec["sources"])
        result.append(rec)
    return result


# ---------------------------------------------------------------------------
# Severity rollup
# ---------------------------------------------------------------------------

def severity_rollup(findings: Iterable[Dict[str, Any]]) -> Dict[str, Any]:
    """Counts per canonical severity, total, and the single highest severity."""
    counts = {s: 0 for s in SEVERITY_ORDER}
    total = 0
    for f in findings:
        counts[normalize_severity(f.get("severity"))] += 1
        total += 1
    highest = next((s for s in SEVERITY_ORDER if counts[s] > 0), None)
    return {"counts": counts, "total": total, "highest": highest}


# ---------------------------------------------------------------------------
# Executive summary
# ---------------------------------------------------------------------------

_POSTURE_BY_HIGHEST: Dict[Optional[str], str] = {
    "CRITICAL": (
        "Critical exposure detected. Immediate remediation is required to "
        "reduce risk of compromise."
    ),
    "HIGH": (
        "High-severity findings present. Prioritize remediation within your "
        "standard high-severity SLA window."
    ),
    "MEDIUM": (
        "Moderate findings identified. Address within the planned remediation "
        "cycle to maintain a strong security posture."
    ),
    "LOW": (
        "Only low-severity findings identified. Posture is solid; resolve these "
        "as routine hygiene."
    ),
    "INFO": (
        "No actionable security weaknesses identified. Informational results "
        "only — posture is clean for the scanned scope."
    ),
    None: (
        "No findings were produced for the scanned scope."
    ),
}


def _asset_of(finding: Dict[str, Any]) -> str:
    return str(
        finding.get("affected_asset")
        or finding.get("host")
        or finding.get("target")
        or ""
    ).strip()


def build_executive_summary(findings: List[Dict[str, Any]],
                            top_n: int = 5) -> Dict[str, Any]:
    """Build the executive-summary block a CISO would forward.

    Returns a dict with:
      * ``total_findings`` / ``severity_counts`` / ``highest_severity``
      * ``assets_affected`` — distinct affected assets
      * ``top_risks`` — up to *top_n* findings, ordered most→least severe
      * ``posture`` — a one-line plain-English risk statement
    """
    roll = severity_rollup(findings)

    assets = {_asset_of(f) for f in findings if _asset_of(f)}

    ranked = sorted(
        findings,
        key=lambda f: (severity_rank(f.get("severity")),
                       -float(f.get("cvss_score") or 0)),
    )
    top_risks = [
        {
            "title": f.get("title", ""),
            "severity": normalize_severity(f.get("severity")),
            "affected_asset": _asset_of(f),
            "finding_type": f.get("finding_type", ""),
            "cvss_score": f.get("cvss_score", 0.0),
        }
        for f in ranked[:top_n]
    ]

    return {
        "total_findings": roll["total"],
        "severity_counts": roll["counts"],
        "highest_severity": roll["highest"],
        "assets_affected": len(assets),
        "top_risks": top_risks,
        "posture": _POSTURE_BY_HIGHEST[roll["highest"]],
    }


# ---------------------------------------------------------------------------
# One-call pipeline for report generators / exporters
# ---------------------------------------------------------------------------

def process_findings(raw_findings: List[Dict[str, Any]]) -> Dict[str, Any]:
    """Full reporting-quality pass over raw scanner findings.

    Steps: drop scanner noise -> normalize severity + ensure remediation ->
    de-duplicate (merging sources) -> build executive summary.

    Returns ``{"findings": [...clean...], "summary": {...}}`` where ``summary``
    additionally carries ``dropped_noise`` and ``deduplicated`` counts so report
    generators can show the cleanup that happened.
    """
    raw = list(raw_findings or [])

    kept = [f for f in raw if not is_noise(f)]
    dropped_noise = len(raw) - len(kept)

    enriched = [ensure_remediation(f) for f in kept]
    for f in enriched:
        f["severity"] = normalize_severity(f.get("severity"))

    deduped = dedupe_findings(enriched)
    deduplicated = len(enriched) - len(deduped)

    summary = build_executive_summary(deduped)
    summary["dropped_noise"] = dropped_noise
    summary["deduplicated"] = deduplicated
    summary["raw_count"] = len(raw)

    return {"findings": deduped, "summary": summary}
