"""
GitHub REPOSITORY security advisories → CVEs for desktop software.

Different source from the GLOBAL advisory API (api.github.com/advisories):
that one mirrors NVD and its 'unreviewed' entries carry no version data at
all. The per-repo endpoint

    https://api.github.com/repos/{owner}/{repo}/security-advisories

is what a project maintainer publishes themselves, and it DOES carry
`vulnerable_version_range` + `patched_versions` (verified live against
notepad-plus-plus: 18 advisories, all 18 with a range, 12 with a CVE id).

That closes a real gap: Notepad++ CVEs (CVE-2026-57233, -54758, -52886, …)
appear in neither NVD nor cvelistV5, so no other scanner path can see them.

Scope: products in `_REPO_MAP` whose upstream publishes advisories this way.
Findings are written through the app-scan upsert with the app-scan source, so
the existing app-scan reconcile auto-resolves them once the host updates —
no separate source/reconcile pair to maintain.
"""
import json
import logging
import re
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple

from sqlalchemy.orm import Session

from app.models.setting import Setting

logger = logging.getLogger(__name__)

INDEX_SETTING = "github_repo_advisory_cache"
INDEX_TS_SETTING = "github_repo_advisory_cache_ts"
TTL_HOURS = 24
_API = "https://api.github.com/repos/{repo}/security-advisories?per_page=100"

# Installed-software name → GitHub repo publishing advisories for it.
# Add a line per product; everything else is untouched.
_REPO_MAP: List[Tuple[re.Pattern, str]] = [
    (re.compile(r"notepad\+\+", re.I), "notepad-plus-plus/notepad-plus-plus"),
]

_SEV = {"critical": "critical", "high": "high", "moderate": "medium",
        "medium": "medium", "low": "low"}


def resolve_repo(product_name: str) -> Optional[str]:
    n = (product_name or "").strip()
    for rx, repo in _REPO_MAP:
        if rx.search(n):
            return repo
    return None


def _vt(s: Optional[str]) -> Optional[tuple]:
    """'v8.9.6.4' → (8,9,6,4). None when not dotted-numeric."""
    s = (s or "").strip().lstrip("vV").strip()
    if not re.fullmatch(r"\d+(\.\d+)*", s):
        return None
    return tuple(int(x) for x in s.split("."))


def is_affected(installed: str, rng: Optional[str], patched: Optional[str]) -> bool:
    """Is `installed` inside the advisory's vulnerable range?

    `vulnerable_version_range` is maintainer free-text — the real Notepad++
    feed uses '<= v8.9.6.4', '< v8.9.6.4', '<=8.9.1', 'old versions - 8.8.1',
    'v8.9.4 & v8.9.5' and bare single versions. `patched_versions` is always a
    clean single literal, so it doubles as the safety net: at/past the patched
    release is never affected, whatever the range text says.
    """
    it = _vt(installed)
    if not it:
        return False
    pt = _vt(patched)
    if pt and it >= pt:
        return False                      # patched — hard stop, no FP possible

    r = (rng or "").strip()
    m = re.match(r"^<=\s*v?([\d.]+)$", r, re.I)
    if m:
        b = _vt(m.group(1))
        return bool(b and it <= b)
    m = re.match(r"^<\s*v?([\d.]+)$", r, re.I)
    if m:
        b = _vt(m.group(1))
        return bool(b and it < b)
    m = re.match(r"^old versions\s*-\s*v?([\d.]+)$", r, re.I)
    if m:
        b = _vt(m.group(1))
        return bool(b and it <= b)
    parts = [p for p in re.split(r"[&,]", r) if p.strip()]
    if len(parts) > 1:                    # explicit list — exact matches only,
        return any(_vt(p) == it for p in parts)   # older builds are NOT affected
    if len(parts) == 1 and _vt(parts[0]):
        return _vt(parts[0]) == it        # single exact version
    return bool(pt and it < pt)           # unparseable range → below-patched rule


def _cvss_of(adv: dict) -> Optional[float]:
    score = (adv.get("cvss") or {}).get("score")
    if not score:
        sev = adv.get("cvss_severities") or {}
        for k in ("cvss_v4", "cvss_v3"):
            s = (sev.get(k) or {}).get("score")
            if s:
                score = s
                break
    try:
        return float(score) if score else None
    except (TypeError, ValueError):
        return None


def build_index(db: Session) -> Dict[str, list]:
    """{repo: [{cve, cvss, sev, range, patched, summary}, …]}, cached 24h."""
    import httpx
    from app.auth.setting_crypto import read_setting_value

    headers = {"Accept": "application/vnd.github+json",
               "X-GitHub-Api-Version": "2022-11-28"}
    try:
        pat = (read_setting_value(db, "github_pat") or "").strip()
        if pat:
            headers["Authorization"] = f"Bearer {pat}"
    except Exception:
        pass

    index: Dict[str, list] = {}
    with httpx.Client(timeout=20.0, follow_redirects=True, headers=headers) as client:
        for _, repo in _REPO_MAP:
            if repo in index:
                continue
            try:
                r = client.get(_API.format(repo=repo))
                if r.status_code == 403 and r.headers.get("x-ratelimit-remaining") == "0":
                    logger.warning("repo-advisories: GitHub rate limit hit — "
                                   "set github_pat for 5000 req/h")
                    break
                if r.status_code != 200:
                    logger.debug("repo-advisories: %s → HTTP %s", repo, r.status_code)
                    continue
                entries = []
                for adv in r.json() or []:
                    cve = (adv.get("cve_id") or "").strip().upper()
                    # CVE-less advisories (GHSA id only) are skipped: the CVE id
                    # is what the rest of the pipeline (enrichment, KEV/EPSS,
                    # dedup across scanners) keys on.
                    if not cve.startswith("CVE-"):
                        continue
                    if adv.get("withdrawn_at"):
                        continue
                    vulns = adv.get("vulnerabilities") or []
                    v0 = vulns[0] if vulns else {}
                    entries.append({
                        "cve": cve,
                        "cvss": _cvss_of(adv),
                        "sev": _SEV.get((adv.get("severity") or "").lower()),
                        "range": v0.get("vulnerable_version_range"),
                        "patched": v0.get("patched_versions"),
                        "summary": (adv.get("summary") or "")[:400],
                    })
                index[repo] = entries
                logger.info("repo-advisories: %s → %d CVE advisories", repo, len(entries))
            except Exception as e:
                logger.debug("repo-advisories: fetch %s failed: %s", repo, e)

    _store(db, index)
    return index


def _store(db: Session, index: Dict[str, list]) -> None:
    for key, val in ((INDEX_SETTING, json.dumps(index)),
                     (INDEX_TS_SETTING, datetime.now().isoformat())):
        row = db.query(Setting).filter(Setting.key == key).first()
        if row:
            row.value = val
        else:
            db.add(Setting(key=key, value=val))
    db.commit()


def load_index(db: Session) -> Optional[Dict[str, list]]:
    ts = db.query(Setting).filter(Setting.key == INDEX_TS_SETTING).first()
    row = db.query(Setting).filter(Setting.key == INDEX_SETTING).first()
    if not ts or not row or not row.value:
        return None
    try:
        if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=TTL_HOURS):
            return None
        return json.loads(row.value)
    except (ValueError, json.JSONDecodeError):
        return None


def get_index(db: Session) -> Dict[str, list]:
    idx = load_index(db)
    if idx is not None:
        return idx
    try:
        return build_index(db)
    except Exception as e:
        logger.warning("repo-advisory index build failed: %s", e)
        return {}


def scan_asset(db: Session, asset, packages: list, new_ids: Optional[list] = None,
               touched: Optional[set] = None) -> int:
    """Match installed software against repo-published advisories.
    Findings go through the app-scan upsert (source 'app-scan'), so the
    existing app-scan reconcile closes them when the host updates.
    Returns findings upserted. Caller commits."""
    from app.services import app_cve_scanner_service as cpe

    index = get_index(db)
    if not index:
        return 0
    if new_ids is None:
        new_ids = []
    count = 0
    seen: set = set()
    for pkg in packages or []:
        name = (pkg.get("name") or "").strip()
        version = (pkg.get("version") or "").strip()
        if not name or not version:
            continue
        if cpe._is_citrix_shim(pkg):
            continue
        repo = resolve_repo(name)
        if not repo or repo not in index:
            continue
        key = (repo, version)
        if key in seen:
            continue
        seen.add(key)
        for e in index[repo]:
            if not is_affected(version, e.get("range"), e.get("patched")):
                continue
            c = {"cve": e["cve"], "cvss": e.get("cvss"), "severity": e.get("sev"),
                 "fixed": (e.get("patched") or "").lstrip("vV") or None}
            try:
                before = len(new_ids)
                cpe._upsert(db, asset, name, version, c, new_ids, touched=touched,
                            vendor=(pkg.get("vendor") or None))
                count += 1 if len(new_ids) > before else 0
            except Exception as ex:
                logger.debug("repo-advisory upsert failed (%s on %s): %s",
                             e["cve"], asset.id, ex)
    return count


if __name__ == "__main__":
    # ponytail: one self-check for the range parser — the only non-trivial
    # logic. Covers every format seen in the live Notepad++ feed.
    # Run: python -m app.services.github_repo_advisory_service
    assert is_affected("8.9.6.4", "<= v8.9.6.4", "v8.9.7") is True    # tester's CVE-2026-57233
    assert is_affected("8.9.7", "<= v8.9.6.4", "v8.9.7") is False     # patched
    assert is_affected("8.9.6", "<= v8.9.6.4", "v8.9.7") is True      # older affected
    assert is_affected("8.9.6.4", "< v8.9.6.4", "v8.9.7") is False    # exclusive bound
    assert is_affected("8.9.0", "<=8.9.1", "8.9.2") is True           # no space, no 'v'
    assert is_affected("8.8.0", "old versions - 8.8.1", "8.8.2") is True
    assert is_affected("8.9.3", "v8.9.4 & v8.9.5", "v8.9.6") is False  # list ≠ range
    assert is_affected("8.9.4", "v8.9.4 & v8.9.5", "v8.9.6") is True
    assert is_affected("8.9.6.1", "v8.9.6.1", "v8.9.6.2") is True      # single exact
    assert is_affected("8.9.5", "v8.9.6.1", "v8.9.6.2") is False
    assert is_affected("9.0", "old versions - 8.8.1", "8.8.2") is False  # safety net
    assert is_affected("8.9.6.4", None, "v8.9.7") is True              # no range → below-patched
    assert is_affected("1:8.9-1", "<= v8.9.6.4", "v8.9.7") is False     # rpm-style → skip
    assert resolve_repo("Notepad++ (64-bit x64)") == "notepad-plus-plus/notepad-plus-plus"
    assert resolve_repo("Google Chrome") is None
    print("github_repo_advisory_service self-check OK")
