"""Offline vulnerability enrichment from local JSON feeds."""

from __future__ import annotations

from pathlib import Path
import re

from sentra.models import DeviceEvidence, FirmwareEvidence, SoftwareComponent, VulnerabilityMatch
from sentra.state import cve_feed_path, load_json

_STOP_WORDS = {"device", "firmware", "service", "embedded", "iot", "the", "and"}


class LocalJsonVulnerabilityProvider:
    name = "local-json"
    feed_name = "workspace-cve-feed"

    def __init__(self, workspace: Path | None) -> None:
        self.workspace = workspace
        self.feed = _load_feed(workspace)

    def match(self, component: SoftwareComponent) -> list[VulnerabilityMatch]:
        if not self.feed:
            return []
        results: list[VulnerabilityMatch] = []
        component_tokens = _candidate_product_names(component)
        component_refs = _component_evidence_refs(component)
        for item in self.feed:
            matched_on = _match_feed_item(item, component_tokens, component.version)
            if not matched_on:
                continue
            category = _match_category(matched_on)
            feed_product = normalize_product_name(str(item.get("product", component.name)))
            confidence = _derive_match_confidence(item, matched_on, component.version, category)
            results.append(
                VulnerabilityMatch(
                    cve_id=str(item.get("cve_id", "UNKNOWN")),
                    vendor=str(item.get("vendor")).strip() if item.get("vendor") else None,
                    product=str(item.get("product", component.name)),
                    normalized_product=feed_product,
                    category=category,
                    severity=str(item.get("severity", "medium")),
                    summary=str(item.get("summary", "Potential vulnerability match.")),
                    fixed_in=str(item["fixed_in"]) if item.get("fixed_in") else None,
                    source=self.name,
                    confidence=confidence,
                    inference=category != "confirmed-match",
                    matched_on=matched_on,
                    evidence_refs=component_refs,
                    rationale=_build_rationale(component, matched_on, category),
                )
            )
        deduped = {(item.cve_id, item.normalized_product, item.category): item for item in results}
        ordered = list(deduped.values())
        ordered.sort(key=lambda item: (-_severity_rank(item.severity), -item.confidence, item.cve_id, item.category))
        return ordered


def enrich_vulnerabilities(device: DeviceEvidence, workspace: Path | None = None) -> DeviceEvidence:
    provider = LocalJsonVulnerabilityProvider(workspace)
    matches = [match for component in device.software_components for match in provider.match(component)]
    deduped = {(item.cve_id, item.normalized_product): item for item in matches}
    ordered = list(deduped.values())
    ordered.sort(key=lambda item: (-_severity_rank(item.severity), -item.confidence, item.cve_id))
    return device.model_copy(update={"vulnerability_matches": ordered})


def enrich_firmware_vulnerabilities(firmware: FirmwareEvidence, workspace: Path | None = None) -> FirmwareEvidence:
    provider = LocalJsonVulnerabilityProvider(workspace)
    matches = [match for component in firmware.software_components for match in provider.match(component)]
    deduped = {(item.cve_id, item.normalized_product): item for item in matches}
    ordered = list(deduped.values())
    ordered.sort(key=lambda item: (-_severity_rank(item.severity), -item.confidence, item.cve_id))
    return firmware.model_copy(update={"vulnerability_matches": ordered})


def normalize_product_name(name: str) -> str:
    lowered = name.strip().lower()
    cleaned = re.sub(r"[^a-z0-9]+", " ", lowered)
    parts = [part for part in cleaned.split() if part and part not in _STOP_WORDS]
    return " ".join(parts)


def _candidate_product_names(component: SoftwareComponent) -> set[str]:
    candidates = {
        normalize_product_name(component.name),
        normalize_product_name(component.normalized_name),
    }
    if component.version:
        candidates.add(normalize_product_name(f"{component.name} {component.version}"))
    expanded: set[str] = set()
    for candidate in candidates:
        if not candidate:
            continue
        expanded.add(candidate)
        expanded.update(_ngrams(candidate))
    return {candidate for candidate in expanded if candidate}


def _match_feed_item(item: dict[str, object], component_names: set[str], version: str | None) -> list[str]:
    matched_on: list[str] = []
    product = normalize_product_name(str(item.get("product", "")))
    vendor = normalize_product_name(str(item.get("vendor", "")))
    aliases = sorted(normalize_product_name(str(alias)) for alias in item.get("aliases", []))
    if product in component_names:
        matched_on.append(f"product:{product}")
    elif any(product and product in candidate for candidate in component_names):
        matched_on.append(f"product-substring:{product}")
    for alias in aliases:
        if alias in component_names:
            matched_on.append(f"alias:{alias}")
        elif any(alias and alias in candidate for candidate in component_names):
            matched_on.append(f"alias-substring:{alias}")
    if vendor and any(vendor in candidate for candidate in component_names):
        matched_on.append(f"vendor:{vendor}")
    affected = str(item.get("affected_version", "")).strip()
    if affected and version and normalize_version(version) == normalize_version(affected):
        matched_on.append(f"version:{affected}")
    elif not affected and version and str(item.get("fixed_in", "")).strip():
        fixed_in = str(item.get("fixed_in", ""))
        if _version_less_than(version, fixed_in):
            matched_on.append(f"version<fixed_in:{fixed_in}")
    return sorted(dict.fromkeys(matched_on))


def _match_category(matched_on: list[str]) -> str:
    has_product = any(reason.startswith("product:") for reason in matched_on)
    has_version = any(reason.startswith("version:") or reason.startswith("version<fixed_in:") for reason in matched_on)
    has_vendor = any(reason.startswith("vendor:") for reason in matched_on)
    has_alias = any(reason.startswith("alias:") or reason.startswith("product-substring:") or reason.startswith("alias-substring:") for reason in matched_on)
    if has_product and has_version:
        return "confirmed-match"
    if has_product or (has_vendor and has_alias) or (has_alias and has_version):
        return "probable-match"
    return "weak-hint"


def _derive_match_confidence(item: dict[str, object], matched_on: list[str], version: str | None, category: str) -> float:
    base = float(item.get("confidence", 0.68))
    category_bonus = {"confirmed-match": 0.14, "probable-match": 0.06, "weak-hint": -0.06}[category]
    if any(reason.startswith("product:") for reason in matched_on):
        base += 0.08
    if any(reason.startswith("product-substring:") for reason in matched_on):
        base -= 0.03
    if any(reason.startswith("alias:") for reason in matched_on):
        base += 0.02
    if any(reason.startswith("alias-substring:") for reason in matched_on):
        base -= 0.04
    if any(reason.startswith("version:") or reason.startswith("version<fixed_in:") for reason in matched_on):
        base += 0.08
    elif version is None:
        base -= 0.04
    return round(min(0.99, max(0.1, base + category_bonus)), 2)


def _load_feed(workspace: Path | None) -> list[dict[str, object]]:
    if workspace is None:
        return []
    path = cve_feed_path(workspace)
    if not path.exists():
        return []
    payload = load_json(path)
    return [item for item in payload if isinstance(item, dict)] if isinstance(payload, list) else []


def _component_evidence_refs(component: SoftwareComponent) -> list[str]:
    refs = [f"component:{normalize_product_name(component.name)}"]
    if component.version:
        refs.append(f"version:{component.version}")
    return refs


def _build_rationale(component: SoftwareComponent, matched_on: list[str], category: str) -> str:
    reasons = ", ".join(matched_on) if matched_on else "heuristic indicators"
    return f"{component.normalized_name} matched as {category} based on {reasons}."


def _severity_rank(severity: str) -> int:
    return {"critical": 4, "high": 3, "medium": 2, "low": 1}.get(severity.lower(), 0)


def _version_less_than(current: str, fixed_in: str) -> bool:
    current_parts = _parse_version(current)
    fixed_parts = _parse_version(fixed_in)
    if not current_parts or not fixed_parts:
        return False
    return current_parts < fixed_parts


def _parse_version(value: str) -> tuple[int, ...]:
    parts: list[int] = []
    for token in normalize_version(value).split("."):
        if token.isdigit():
            parts.append(int(token))
    return tuple(parts)


def normalize_version(value: str) -> str:
    normalized = re.sub(r"[^0-9.]+", ".", value.strip().lower())
    normalized = re.sub(r"\.+", ".", normalized).strip(".")
    return normalized


def _ngrams(value: str) -> set[str]:
    parts = value.split()
    if len(parts) < 2:
        return set()
    return {" ".join(parts[index : index + 2]) for index in range(len(parts) - 1)}

