#!/usr/bin/env python3
"""
TDD tests for the signed intel pipeline (bin/intel-pipeline.py + lib/intel_snapshot.py).

Covers, with a THROWAWAY keypair and synthetic stale/fresh data:
  - sign -> verify round trip (signature accepted)
  - tamper rejection: mutate a manifest field after signing -> rejected
  - tamper rejection: mutate the on-disk DB so its hash no longer matches -> rejected
  - wrong-key rejection: sign with key A, verify trusting key B -> rejected
  - freshness SLA: fresh snapshot trusted; stale (old generated_at) rejected
  - per-source SLA: KEV age > 48h marks snapshot stale
  - fail-safe: missing manifest -> trusted=False, not an exception

Run directly:  python bin/test_intel_pipeline.py
Or:            python -m pytest bin/test_intel_pipeline.py -v
"""
from __future__ import annotations

import importlib.util
import json
import os
import sqlite3
import sys
import tempfile
from datetime import datetime, timedelta, timezone
from pathlib import Path

import pytest

BIN_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = BIN_DIR.parent
sys.path.insert(0, str(PROJECT_ROOT / "lib"))


def _load_pipeline_module():
    spec = importlib.util.spec_from_file_location(
        "intel_pipeline", BIN_DIR / "intel-pipeline.py"
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


pipeline = _load_pipeline_module()
import intel_snapshot  # noqa: E402


# --------------------------------------------------------------------------
# Fixtures: throwaway keys + synthetic intel data dir
# --------------------------------------------------------------------------

@pytest.fixture()
def throwaway_keys(tmp_path):
    keys = pipeline.gen_throwaway_keys(tmp_path / "keys")
    # Point the verifier at the throwaway public keys for the whole test.
    os.environ["DONJON_INTEL_PUBKEY_CLASSICAL_B64"] = keys["classical_pub_b64"]
    if "pqc_pub_b64" in keys:
        os.environ["DONJON_INTEL_PUBKEY_PQC_B64"] = keys["pqc_pub_b64"]
    yield keys
    os.environ.pop("DONJON_INTEL_PUBKEY_CLASSICAL_B64", None)
    os.environ.pop("DONJON_INTEL_PUBKEY_PQC_B64", None)


@pytest.fixture()
def synthetic_data_dir(tmp_path):
    """A minimal but real intel data dir: tiny sqlite DBs + a KEV json."""
    d = tmp_path / "data"
    (d / "vuln_db").mkdir(parents=True)
    (d / "threat_intel").mkdir(parents=True)

    vdb = d / "vuln_db" / "vuln_intel.db"
    conn = sqlite3.connect(str(vdb))
    conn.execute("CREATE TABLE nvd_cves (cve_id TEXT PRIMARY KEY, description TEXT)")
    conn.execute("CREATE TABLE epss_scores (cve_id TEXT PRIMARY KEY, epss REAL)")
    conn.executemany(
        "INSERT INTO nvd_cves VALUES (?, ?)",
        [("CVE-2024-0001", "synthetic"), ("CVE-2024-0002", "synthetic")],
    )
    conn.commit()
    conn.close()

    feeds = d / "intel_feeds.db"
    conn = sqlite3.connect(str(feeds))
    for t in ("exploitdb", "osv_vulns", "github_advisories", "cisa_alerts", "mitre_attack"):
        conn.execute(f'CREATE TABLE "{t}" (id INTEGER PRIMARY KEY)')
    conn.execute("INSERT INTO exploitdb DEFAULT VALUES")
    conn.commit()
    conn.close()

    kev = d / "threat_intel" / "cisa_kev.json"
    kev.write_text(json.dumps({"vulnerabilities": [{"cveID": "CVE-2024-0001"}]}), "utf-8")
    return d


def _sign(manifest, keys):
    ck = Path(keys["classical_key"])
    pk = Path(keys["pqc_key"]) if "pqc_key" in keys else None
    return pipeline.sign_manifest(manifest, ck, pk)


# --------------------------------------------------------------------------
# Tests
# --------------------------------------------------------------------------

def test_sign_verify_roundtrip(throwaway_keys, synthetic_data_dir, tmp_path):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    assert manifest["totals"]["records"] == 4  # 2 nvd + 1 exploitdb + 1 kev (epss/etc=0)
    signed = _sign(manifest, throwaway_keys)
    assert "classical" in signed["signatures"]

    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.signature_ok is True
    assert result.integrity_ok is True
    assert result.fresh is True
    assert result.trusted is True, result.reasons


def test_tamper_manifest_field_rejected(throwaway_keys, synthetic_data_dir):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    signed = _sign(manifest, throwaway_keys)
    # Attacker bumps the record count after signing.
    signed["totals"]["records"] = 999999
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.signature_ok is False
    assert result.trusted is False


def test_tamper_db_hash_mismatch_rejected(throwaway_keys, synthetic_data_dir):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    signed = _sign(manifest, throwaway_keys)
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    # Tamper with the on-disk DB AFTER the manifest was signed.
    vdb = synthetic_data_dir / "vuln_db" / "vuln_intel.db"
    conn = sqlite3.connect(str(vdb))
    conn.execute("INSERT INTO nvd_cves VALUES ('CVE-2024-9999', 'INJECTED')")
    conn.commit()
    conn.close()

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.signature_ok is True  # signature still valid over the manifest
    assert result.integrity_ok is False  # but on-disk data no longer matches
    assert result.trusted is False
    assert any("hash mismatch" in r for r in result.reasons)


def test_wrong_key_rejected(throwaway_keys, synthetic_data_dir, tmp_path):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    signed = _sign(manifest, throwaway_keys)
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    # Verifier now trusts a DIFFERENT key — must reject.
    other = pipeline.gen_throwaway_keys(tmp_path / "other-keys")
    os.environ["DONJON_INTEL_PUBKEY_CLASSICAL_B64"] = other["classical_pub_b64"]
    if "pqc_pub_b64" in other:
        os.environ["DONJON_INTEL_PUBKEY_PQC_B64"] = other["pqc_pub_b64"]

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.signature_ok is False
    assert result.trusted is False


def test_stale_snapshot_rejected(throwaway_keys, synthetic_data_dir):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    # Backdate generated_at beyond the 7-day SLA.
    manifest["generated_at"] = (
        datetime.now(timezone.utc) - timedelta(days=80)
    ).isoformat()
    signed = _sign(manifest, throwaway_keys)
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.signature_ok is True
    assert result.fresh is False
    assert result.stale is True
    assert result.trusted is False
    assert any("days old" in r for r in result.reasons)


def test_fresh_within_sla_trusted(throwaway_keys, synthetic_data_dir):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    manifest["generated_at"] = (
        datetime.now(timezone.utc) - timedelta(days=2)
    ).isoformat()
    # Clear per-source ages so only the overall SLA is exercised here.
    for s in manifest["sources"].values():
        s.pop("age_hours", None)
    signed = _sign(manifest, throwaway_keys)
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.fresh is True
    assert result.trusted is True, result.reasons


def test_per_source_kev_staleness(throwaway_keys, synthetic_data_dir):
    manifest = pipeline.build_snapshot_manifest(synthetic_data_dir)
    manifest["generated_at"] = datetime.now(timezone.utc).isoformat()  # overall fresh
    manifest["sources"]["cisa_kev"]["age_hours"] = 200.0  # > 48h threshold
    signed = _sign(manifest, throwaway_keys)
    mpath = synthetic_data_dir / intel_snapshot.SNAPSHOT_MANIFEST_NAME
    mpath.write_text(json.dumps(signed), "utf-8")

    result = intel_snapshot.verify_snapshot(mpath, data_dir=synthetic_data_dir)
    assert result.fresh is False
    assert result.trusted is False
    assert any("cisa_kev" in r and "threshold" in r for r in result.reasons)


def test_missing_manifest_failsafe(throwaway_keys, synthetic_data_dir):
    result = intel_snapshot.verify_snapshot(
        synthetic_data_dir / "does-not-exist.json", data_dir=synthetic_data_dir
    )
    assert result.trusted is False
    assert result.signature_ok is False
    assert any("not found" in r for r in result.reasons)


def test_load_snapshot_status_missing_is_stale(throwaway_keys, tmp_path):
    # No manifest in the dir => must report stale/untrusted, never green.
    status = intel_snapshot.load_snapshot_status(tmp_path)
    assert status["trusted"] is False
    assert status["stale"] is True


if __name__ == "__main__":
    sys.exit(pytest.main([__file__, "-v"]))
