```python
#!/usr/bin/env python3
"""Prioritize vulnerabilities using threat intelligence and asset context.

This script ingests one or two CSV files:

1. Vulnerability inventory CSV (required)
2. Optional threat intelligence CSV

It produces a ranked remediation queue with a simple, explicit priority model
based on active exploitation, exploit availability, exposure, and business impact.

The goal is not to replace your GRC or ticketing system. The goal is to give
engineering and security teams a repeatable decision workflow that separates
high-confidence, high-impact risk from lower-priority findings.

Example usage:

    python prioritize_vulns.py \
        --vulns vuln_inventory.csv \
        --threat-intel threat_intel.csv \
        --output prioritized_queue.csv

Vulnerability CSV columns (required unless noted):
    asset_id               Unique asset identifier
    vuln_id                CVE or equivalent identifier
    severity                Base severity or score (e.g., 9.8, Critical)
    detected_date          Date detected (YYYY-MM-DD recommended)
    product               Affected product name
    version               Affected version
    environment           Production, dev, test, etc.
    exposure               Optional exposure tier if already known
    business_impact       Optional impact label or numeric score

Threat intelligence CSV columns (optional):
    vuln_id                CVE or equivalent identifier
    intel_signal           One of:
                           confirmed_exploitation,
                           active_exploitation,
                           exploit_available,
                           campaign_association,
                           awareness_only
    source                 Optional source name
    confidence             Optional confidence label/score
    notes                  Optional free-text notes

Output columns:
    asset_id, vuln_id, priority, score, reason, exposure_tier,
    intel_signal, business_impact, severity, product, version, environment,
    detected_date

The scoring model is intentionally simple and transparent:
    - Threat intelligence signal weight
    - Exposure weight
    - Business impact weight
    - Base severity weight

You can tune the weights in the PRIORITY_WEIGHTS dictionary below.
"""

from __future__ import annotations

import argparse
import csv
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple


PRIORITY_WEIGHTS = {
    "confirmed_exploitation": 50,
    "active_exploitation": 45,
    "exploit_available": 30,
    "campaign_association": 20,
    "awareness_only": 5,
    "unknown": 0,
}

EXPOSURE_WEIGHTS = {
    "internet-facing": 40,
    "partner-customer-reachable": 30,
    "internal-widely-reachable": 20,
    "segmented-restricted": 10,
    "lab-test-isolated": 0,
    "unknown": 10,
}

BUSINESS_WEIGHTS = {
    "critical": 40,
    "high": 30,
    "medium": 20,
    "low": 10,
    "unknown": 15,
}


REQUIRED_VULN_COLUMNS = {
    "asset_id",
    "vuln_id",
    "severity",
    "detected_date",
    "product",
    "version",
    "environment",
}

OPTIONAL_VULN_COLUMNS = {
    "exposure",
    "business_impact",
}

REQUIRED_INTEL_COLUMNS = {"vuln_id", "intel_signal"}


@dataclass
class VulnerabilityRecord:
    asset_id: str
    vuln_id: str
    severity: str
    detected_date: str
    product: str
    version: str
    environment: str
    exposure: str = "unknown"
    business_impact: str = "unknown"


@dataclass
class ThreatIntelRecord:
    vuln_id: str
    intel_signal: str
    source: str = ""
    confidence: str = ""
    notes: str = ""


def normalize_text(value: Optional[str], default: str = "unknown") -> str:
    if value is None:
        return default
    text = str(value).strip()
    return text if text else default


def normalize_key(value: Optional[str]) -> str:
    return normalize_text(value).lower().replace("_", "-")


def parse_csv(path: Path) -> List[Dict[str, str]]:
    if not path.exists():
        raise FileNotFoundError(f"File not found: {path}")
    if not path.is_file():
        raise ValueError(f"Not a file: {path}")

    with path.open("r", newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        rows = list(reader)
        if reader.fieldnames is None:
            raise ValueError(f"No header row found in {path}")
        return rows


def validate_columns(rows: List[Dict[str, str]], required: Iterable[str], label: str) -> None:
    if not rows:
        raise ValueError(f"No rows found in {label} file")

    columns = set(rows[0].keys())
    missing = set(required) - columns
    if missing:
        raise ValueError(f"Missing required columns in {label}: {', '.join(sorted(missing))}")


def parse_date(value: str) -> Optional[datetime]:
    if not value or value == "unknown":
        return None
    for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%Y/%m/%d"):
        try:
            return datetime.strptime(value.strip(), fmt)
        except ValueError:
            continue
    return None


def severity_to_score(severity: str) -> int:
    raw = normalize_text(severity).lower()
    mapping = {
        "critical": 40,
        "high": 30,
        "medium": 20,
        "low": 10,
        "informational": 0,
    }
    if raw in mapping:
        return mapping[raw]
    try:
        score = float(raw)
        if score >= 9.0:
            return 40
        if score >= 7.0:
            return 30
        if score >= 4.0:
            return 20
        if score > 0:
            return 10
    except ValueError:
        pass
    return 15


def classify_priority(score: int) -> str:
    if score >= 90:
        return "P1"
    if score >= 70:
        return "P2"
    if score >= 40:
        return "P3"
    return "P4"


def build_intel_index(rows: List[Dict[str, str]]) -> Dict[str, ThreatIntelRecord]:
    index: Dict[str, ThreatIntelRecord] = {}
    for row in rows:
        vuln_id = normalize_text(row.get("vuln_id"))
        signal = normalize_key(row.get("intel_signal"))
        if vuln_id == "unknown":
            continue
        if signal not in PRIORITY_WEIGHTS:
            signal = "unknown"
        index[vuln_id] = ThreatIntelRecord(
            vuln_id=vuln_id,
            intel_signal=signal,
            source=normalize_text(row.get("source"), default=""),
            confidence=normalize_text(row.get("confidence"), default=""),
            notes=normalize_text(row.get("notes"), default=""),
        )
    return index


def compute_score(vuln: VulnerabilityRecord, intel: ThreatIntelRecord) -> Tuple[int, List[str]]:
    reasons: List[str] = []
    score = 0

    sev_score = severity_to_score(vuln.severity)
    score += sev_score
    reasons.append(f"severity={sev_score}")

    intel_weight = PRIORITY_WEIGHTS.get(intel.intel_signal, 0)
    score += intel_weight
    if intel.intel_signal != "unknown":
        reasons.append(f"intel={intel.intel_signal}:{intel_weight}")

    exposure = normalize_key(vuln.exposure)
    exposure_weight = EXPOSURE_WEIGHTS.get(exposure, EXPOSURE_WEIGHTS["unknown"])
    score += exposure_weight
    reasons.append(f"exposure={exposure}:{exposure_weight}")

    business = normalize_key(vuln.business_impact)
    business_weight = BUSINESS_WEIGHTS.get(business, BUSINESS_WEIGHTS["unknown"])
    score += business_weight
    reasons.append(f"business={business}:{business_weight}")

    env = normalize_key(vuln.environment)
    if env in {"prod", "production"}:
        score += 10
        reasons.append("production:+10")

    detected = parse_date(vuln.detected_date)
    if detected is None:
        reasons.append("detected_date=unparsed")

    return score, reasons


def load_vulnerabilities(path: Path) -> List[VulnerabilityRecord]:
    rows = parse_csv(path)
    validate_columns(rows, REQUIRED_VULN_COLUMNS, "vulnerability inventory")

    records: List[VulnerabilityRecord] = []
    seen = set()
    for row in rows:
        asset_id = normalize_text(row.get("asset_id"))
        vuln_id = normalize_text(row.get("vuln_id"))
        key = (asset_id, vuln_id, normalize_text(row.get("product")), normalize_text(row.get("version")))
        if asset_id == "unknown" or vuln_id == "unknown":
            continue
        if key in seen:
            continue
        seen.add(key)
        records.append(
            VulnerabilityRecord(
                asset_id=asset_id,
                vuln_id=vuln_id,
                severity=normalize_text(row.get("severity")),
                detected_date=normalize_text(row.get("detected_date")),
                product=normalize_text(row.get("product")),
                version=normalize_text(row.get("version")),
                environment=normalize_text(row.get("environment")),
                exposure=normalize_text(row.get("exposure")),
                business_impact=normalize_text(row.get("business_impact")),
            )
        )
    return records


def write_output(path: Path, rows: List[Dict[str, str]]) -> None:
    fieldnames = [
        "asset_id",
        "vuln_id",
        "priority",
        "score",
        "reason",
        "exposure_tier",
        "intel_signal",
        "business_impact",
        "severity",
        "product",
        "version",
        "environment",
        "detected_date",
    ]
    with path.open("w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)


def main(argv: Optional[List[str]] = None) -> int:
    parser = argparse.ArgumentParser(
        description="Rank vulnerabilities using threat intelligence, exposure, and business impact."
    )
    parser.add_argument("--vulns", required=True, help="Path to vulnerability inventory CSV")
    parser.add_argument("--threat-intel", help="Optional path to threat intelligence CSV")
    parser.add_argument("--output", required=True, help="Output CSV path")
    args = parser.parse_args(argv)

    vuln_path = Path(args.vulns)
    output_path = Path(args.output)

    vulnerabilities = load_vulnerabilities(vuln_path)
    intel_index: Dict[str, ThreatIntelRecord] = {}

    if args.threat_intel:
        intel_rows = parse_csv(Path(args.threat_intel))
        validate_columns(intel_rows, REQUIRED_INTEL_COLUMNS, "threat intelligence")
        intel_index = build_intel_index(intel_rows)

    output_rows: List[Dict[str, str]] = []
    for vuln in vulnerabilities:
        intel = intel_index.get(vuln.vuln_id, ThreatIntelRecord(vuln_id=vuln.vuln_id, intel_signal="unknown"))
        score, reasons = compute_score(vuln, intel)
        output_rows.append(
            {
                "asset_id": vuln.asset_id,
                "vuln_id": vuln.vuln_id,
                "priority": classify_priority(score),
                "score": str(score),
                "reason": "; ".join(reasons),
                "exposure_tier": normalize_key(vuln.exposure),
                "intel_signal": intel.intel_signal,
                "business_impact": normalize_key(vuln.business_impact),
                "severity": vuln.severity,
                "product": vuln.product,
                "version": vuln.version,
                "environment": vuln.environment,
                "detected_date": vuln.detected_date,
            }
        )

    output_rows.sort(key=lambda r: (r["priority"], int(r["score"])), reverse=False)
    # Sort with higher score first within priority buckets
    output_rows.sort(key=lambda r: int(r["score"]), reverse=True)

    write_output(output_path, output_rows)
    print(f"Wrote {len(output_rows)} prioritized vulnerability records to {output_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```