"""
Security advisory RSS/Atom feeds → central awareness page.

Complements the CISA-KEV feed with vendor/CERT advisories that often precede
NVD/cvelistV5 publication (tester: newest 7-Zip advisory was on ZDI before
either). Feeds are configurable (setting `advisory_feeds_config`); the parsed
items are cached in a setting so the page renders instantly and the fetch cost
is paid by the scheduler, not the request.

Default feeds were verified live before shipping:
  zdi-published / zdi-upcoming  — Zero Day Initiative
  cert-eu                       — CERT-EU security advisories
  bsi-wid                       — BSI / CERT-Bund WID advisories
  cisco-psirt                   — Cisco PSIRT (feed emits trailing junk after
                                  the XML root → lenient per-item fallback)
"""
from __future__ import annotations

import json
import logging
import re
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import List, Optional

from sqlalchemy.orm import Session

logger = logging.getLogger(__name__)

CONFIG_SETTING = "advisory_feeds_config"
CACHE_SETTING = "advisory_feeds_cache"
MAX_ITEMS_PER_FEED = 30
HTTP_TIMEOUT = 30

DEFAULT_FEEDS: List[dict] = [
    {"id": "zdi-published", "name": "Zero Day Initiative — Published",
     "url": "https://www.zerodayinitiative.com/rss/published/", "enabled": True},
    {"id": "zdi-upcoming", "name": "Zero Day Initiative — Upcoming",
     "url": "https://www.zerodayinitiative.com/rss/upcoming/", "enabled": False},
    {"id": "cert-eu", "name": "CERT-EU Security Advisories",
     "url": "https://cert.europa.eu/publications/security-advisories-rss", "enabled": True},
    {"id": "bsi-wid", "name": "BSI / CERT-Bund (WID)",
     "url": "https://wid.cert-bund.de/content/public/securityAdvisory/rss", "enabled": True},
    {"id": "cisco-psirt", "name": "Cisco PSIRT Advisories",
     "url": "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml", "enabled": False},
]

_ATOM = "{http://www.w3.org/2005/Atom}"
_TAG_RE = re.compile(r"<[^>]+>")
_ITEM_RE = re.compile(r"<item[\s>].*?</item>", re.S | re.I)


def get_feed_config(db: Session) -> List[dict]:
    """Configured feeds; defaults when the setting is unset/broken."""
    from app.models.setting import Setting
    try:
        row = db.query(Setting).filter(Setting.key == CONFIG_SETTING).first()
        if row and row.value:
            cfg = json.loads(row.value)
            if isinstance(cfg, list) and cfg:
                for f in cfg:  # migrate stale Cisco RSS URL (rss.x?i=44 → DTD-refused)
                    if isinstance(f.get("url"), str) and "rss.x?i=44" in f["url"]:
                        f["url"] = "https://sec.cloudapps.cisco.com/security/center/psirtrss20/CiscoSecurityAdvisory.xml"
                return [f for f in cfg if f.get("url")]
    except Exception as e:
        logger.warning("advisory-feeds: config unreadable, using defaults: %s", e)
    return DEFAULT_FEEDS


def _text(el) -> str:
    return "" if el is None or el.text is None else el.text.strip()


def _first(node, *tags):
    for t in tags:
        el = node.find(t)
        if el is not None:
            return el
    return None


_DTD_RE = re.compile(rb"<!(?:DOCTYPE|ENTITY)", re.I)


def _parse_items(raw: bytes) -> List[dict]:
    """RSS <item> / Atom <entry> → dicts. Falls back to per-item regex slicing
    for feeds that emit junk after the XML root (Cisco).

    Security: any DOCTYPE/ENTITY declaration is rejected outright — legitimate
    feeds never need DTDs, and refusing them shuts down XXE and billion-laughs
    entity-expansion attacks without pulling in defusedxml."""
    if _DTD_RE.search(raw):
        raise ValueError("feed contains DOCTYPE/ENTITY declarations — refused")
    try:
        root = ET.fromstring(raw)
        nodes = root.findall(".//item") or root.findall(f".//{_ATOM}entry")
    except ET.ParseError:
        text = raw.decode("utf-8", "replace")
        nodes = []
        for m in _ITEM_RE.findall(text):
            try:
                nodes.append(ET.fromstring(m))
            except ET.ParseError:
                continue
    items = []
    for n in nodes[: MAX_ITEMS_PER_FEED * 2]:
        title = _text(_first(n, "title", f"{_ATOM}title"))
        link_el = _first(n, "link", f"{_ATOM}link")
        link = _text(link_el)
        if not link and link_el is not None:  # Atom: href attribute
            link = (link_el.get("href") or "").strip()
        date = _text(_first(n, "pubDate", f"{_ATOM}updated", f"{_ATOM}published", "dc:date"))
        summary = _TAG_RE.sub(" ", _text(_first(n, "description", f"{_ATOM}summary")))[:400].strip()
        if title:
            items.append({"title": title[:300], "link": link[:1000],
                          "date": date[:64], "summary": summary})
        if len(items) >= MAX_ITEMS_PER_FEED:
            break
    return items


def refresh_feeds(db: Session) -> dict:
    """Fetch every ENABLED feed, cache the parsed items. Per-feed errors are
    recorded on the feed (page shows them) and never fail the run."""
    from app.models.setting import Setting
    out = {"fetched_at": datetime.now().isoformat(), "feeds": []}
    ok = failed = 0
    for f in get_feed_config(db):
        entry = {"id": f.get("id"), "name": f.get("name") or f.get("id"),
                 "url": f["url"], "enabled": bool(f.get("enabled")),
                 "items": [], "error": None}
        if entry["enabled"]:
            try:
                raw = urllib.request.urlopen(
                    urllib.request.Request(f["url"], headers={"User-Agent": "truevuln-feed/1.0"}),
                    timeout=HTTP_TIMEOUT).read()
                entry["items"] = _parse_items(raw)
                ok += 1
            except Exception as e:
                entry["error"] = f"{type(e).__name__}: {e}"[:200]
                failed += 1
                logger.warning("advisory-feeds: %s failed: %s", f.get("id"), e)
        out["feeds"].append(entry)

    payload = json.dumps(out)
    row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
    if row:
        row.value = payload
    else:
        db.add(Setting(key=CACHE_SETTING, value=payload,
                       description="Cached security-advisory feed items"))
    db.commit()
    logger.info("advisory-feeds: refreshed (%d ok, %d failed)", ok, failed)
    return {"ok": ok, "failed": failed, "feeds": len(out["feeds"])}


def get_cached_feeds(db: Session) -> Optional[dict]:
    from app.models.setting import Setting
    row = db.query(Setting).filter(Setting.key == CACHE_SETTING).first()
    if not row or not row.value:
        return None
    try:
        return json.loads(row.value)
    except Exception:
        return None
