"""
Microsoft product-lifecycle EOL detection (Plan O).

endoflife.date covers the popular products well, but Microsoft "exotics"
(and many server/SKU variants) are not there. Microsoft has no lifecycle
API. The only machine-readable primary source is the monthly Excel export
linked from:

    https://learn.microsoft.com/en-us/lifecycle/products/export/

That page links a file like
    https://download.microsoft.com/download/<guid>/eos-product-listing-<month>-<year>.xlsx
whose GUID + filename change every month — so we scrape the page for the
current link, download the .xlsx, and parse it.

Sheet columns: ListingName | Release | AzureFeature | EndDate
e.g. ("Microsoft SQL Server 2014", "Service Pack 3", None, 2024-07-09)

A few true exotics are NOT in the export at all (the tester called these
out): Silverlight and the Visual C++ Redistributables have their own
single pages. Those are hardcoded below — their EOL dates are fixed and
never change.

This is a *fallback* EOL source: the EOL check consults endoflife.date
first and only falls back here for names endoflife.date can't map.
"""
import json
import logging
import re
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional

import httpx
from sqlalchemy.orm import Session

from app.models.setting import Setting
from app.services.eol_service import EOLStatus, EOL_SOON_DAYS, _days_until

logger = logging.getLogger(__name__)

EXPORT_PAGE_URL = "https://learn.microsoft.com/en-us/lifecycle/products/export/"
# The xlsx link on that page. GUID + month change monthly.
_XLSX_RE = re.compile(
    r"https://download\.microsoft\.com/download/[^\s\"'>]+?"
    r"eos-product-listing-[^\s\"'>]+?\.xlsx",
    re.IGNORECASE,
)

# ---------- cache (settings table) ----------
MSL_CACHE_KEY = "ms_lifecycle_cache"
MSL_CACHE_TS_KEY = "ms_lifecycle_cache_updated_at"
MSL_TTL_HOURS = 24

# In-process memo. resolve_ms_lifecycle_eol() runs once PER PACKAGE across every
# synced device; without this, a fresh 24h DB cache still let each concurrent
# device re-download the export before the first _store_cache commit landed —
# the tester saw dozens of identical GET .../lifecycle/products/export/ per sync.
# This holds the parsed rows in the worker for a short window so one sync fetches
# at most once. ponytail: module-global memo, fine for a read-only reference list.
_MEM_ROWS: Optional[List[dict]] = None
_MEM_TS: float = 0.0
_MEM_TTL_SEC = 3600

# ---------- toggle ----------
SETTING_MSL_ENABLED = "ms_lifecycle_enabled"

HTTP_TIMEOUT = 60.0


class MSLifecycleError(Exception):
    """Raised when the MS lifecycle export cannot be fetched/parsed."""


# ============================================================
# hardcoded exotics (not in the export file)
# ============================================================
# {match_substring: (display_name, eol_date_iso)}. Matched case-insensitive
# against the syscollector product name. EOL dates are fixed/announced.
_HARDCODED_EOL: Dict[str, tuple] = {
    # https://learn.microsoft.com/lifecycle/announcements/silverlight-end-of-support
    "silverlight": ("Microsoft Silverlight", "2021-10-12"),
    # Visual C++ Redistributables track their Visual Studio lifecycle. The
    # runtimes from EOL Visual Studio versions are themselves out of
    # support. (Latest 2015-2022 redist stays supported — not listed.)
    # https://learn.microsoft.com/cpp/windows/latest-supported-vc-redist
    "visual c++ 2013": ("Visual C++ 2013 Redistributable", "2024-04-09"),
    "visual c++ 2012": ("Visual C++ 2012 Redistributable", "2023-01-10"),
    "visual c++ 2010": ("Visual C++ 2010 Redistributable", "2020-07-14"),
    "visual c++ 2008": ("Visual C++ 2008 Redistributable", "2018-04-10"),
}


def _normalise(name: str) -> str:
    n = (name or "").lower()
    n = n.replace("microsoft", " ").replace("(r)", " ").replace("®", " ")
    n = re.sub(r"[^a-z0-9]+", " ", n)
    return re.sub(r"\s+", " ", n).strip()


# ============================================================
# fetch + parse
# ============================================================

def _find_xlsx_url(html: str) -> Optional[str]:
    m = _XLSX_RE.search(html)
    return m.group(0) if m else None


def _parse_xlsx(content: bytes) -> List[dict]:
    """Parse the eos-product-listing workbook into row dicts."""
    import io
    import openpyxl

    wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True, data_only=True)
    ws = wb[wb.sheetnames[0]]
    rows = ws.iter_rows(values_only=True)
    header = next(rows, None)
    if not header:
        return []
    # Tolerate column reordering by mapping header names.
    idx = {str(h).strip().lower(): i for i, h in enumerate(header) if h}
    i_name = idx.get("listingname", 0)
    i_rel = idx.get("release", 1)
    i_end = idx.get("enddate", 3)

    out: List[dict] = []
    for r in rows:
        if not r or len(r) <= i_end:
            continue
        name = r[i_name]
        end = r[i_end]
        if not name or end is None:
            continue
        if isinstance(end, (datetime, date)):
            end_iso = end.strftime("%Y-%m-%d")
        else:
            # occasionally a string date — keep first 10 chars if ISO-ish
            s = str(end).strip()
            end_iso = s[:10] if re.match(r"\d{4}-\d{2}-\d{2}", s) else None
        if not end_iso:
            continue
        out.append({
            "name": str(name).strip(),
            "release": str(r[i_rel]).strip() if (len(r) > i_rel and r[i_rel]) else "",
            "end_date": end_iso,
        })
    return out


def _load_cache(db: Session) -> Optional[List[dict]]:
    ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
    cache = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
    if not ts or not cache or not cache.value:
        return None
    try:
        if datetime.now() - datetime.fromisoformat(ts.value) > timedelta(hours=MSL_TTL_HOURS):
            return None
        return json.loads(cache.value)
    except (ValueError, json.JSONDecodeError):
        return None


def _store_cache(db: Session, rows: List[dict]) -> None:
    c = db.query(Setting).filter(Setting.key == MSL_CACHE_KEY).first()
    if c:
        c.value = json.dumps(rows)
    else:
        db.add(Setting(key=MSL_CACHE_KEY, value=json.dumps(rows),
                       description="MS lifecycle EOL export cache (24h)"))
    ts = db.query(Setting).filter(Setting.key == MSL_CACHE_TS_KEY).first()
    if ts:
        ts.value = datetime.now().isoformat()
    else:
        db.add(Setting(key=MSL_CACHE_TS_KEY, value=datetime.now().isoformat(),
                       description="Timestamp of last MS lifecycle export parse"))
    db.commit()


def fetch_lifecycle_data(db: Session, force_refresh: bool = False) -> List[dict]:
    """Return parsed lifecycle rows, cached 24h in the settings table."""
    global _MEM_ROWS, _MEM_TS
    import time as _t
    if not force_refresh:
        # In-process memo first — collapses the per-package/per-device burst.
        if _MEM_ROWS is not None and (_t.time() - _MEM_TS) < _MEM_TTL_SEC:
            return _MEM_ROWS
        cached = _load_cache(db)
        if cached is not None:
            _MEM_ROWS, _MEM_TS = cached, _t.time()
            return cached
    try:
        with httpx.Client(timeout=HTTP_TIMEOUT, follow_redirects=True,
                          headers={"User-Agent": "Mozilla/5.0 TrueVuln/1.0"}) as client:
            page = client.get(EXPORT_PAGE_URL)
            page.raise_for_status()
            xlsx_url = _find_xlsx_url(page.text)
            if not xlsx_url:
                _MEM_ROWS, _MEM_TS = [], _t.time()
                raise MSLifecycleError("export xlsx link not found on the page (layout changed?)")
            xlsx = client.get(xlsx_url)
            xlsx.raise_for_status()
            rows = _parse_xlsx(xlsx.content)
    except httpx.HTTPError as e:
        # Remember the FAILURE too. Without this every one of the thousands of
        # scanned packages re-fetched the export page (tester saw an endless
        # run of GET .../lifecycle/products/export/ and the UI timing out),
        # because the memo was only ever set on success.
        _MEM_ROWS, _MEM_TS = [], _t.time()
        raise MSLifecycleError(f"could not fetch MS lifecycle export: {e}") from e

    if not rows:
        _MEM_ROWS, _MEM_TS = [], _t.time()
        raise MSLifecycleError("MS lifecycle export parsed to zero rows")
    _store_cache(db, rows)
    _MEM_ROWS, _MEM_TS = rows, _t.time()
    logger.info("MS lifecycle: parsed %d product rows", len(rows))
    return rows


# ============================================================
# resolver
# ============================================================

def _status_from_end_date(
    *, display_name: str, release: str, end_iso: str, installed_version: str
) -> EOLStatus:
    days = _days_until(end_iso)
    is_eol = days is not None and days < 0
    is_soon = days is not None and 0 <= days <= EOL_SOON_DAYS
    slug = "ms-lifecycle"
    return EOLStatus(
        is_eol=is_eol,
        is_eoas=False,
        is_maintained=not is_eol,
        is_eol_soon=is_soon,
        days_to_eol=days,
        release_label=release or display_name,
        release_name=(release or display_name)[:40],
        eol_date=end_iso,
        product_slug=slug,
        latest_version=None,
    )


def resolve_ms_lifecycle_eol(
    db: Session, product_name: str, installed_version: str = ""
) -> Optional[EOLStatus]:
    """Resolve an EOL status for an MS product via hardcoded exotics first,
    then the lifecycle export. Returns None when no match or no EOL signal.
    """
    pname = product_name or ""

    # 1) hardcoded exotics (Silverlight, old VC++ redists)
    low = pname.lower()
    for needle, (disp, end_iso) in _HARDCODED_EOL.items():
        if needle in low:
            st = _status_from_end_date(
                display_name=disp, release="", end_iso=end_iso,
                installed_version=installed_version,
            )
            if st.is_eol or st.is_eol_soon:
                return st
            return None

    # 2) lifecycle export — match listing by normalised name, then take the
    #    LATEST end date among matching rows (the last service pack defines
    #    when security support truly ends).
    try:
        rows = fetch_lifecycle_data(db)
    except MSLifecycleError as e:
        logger.debug("MS lifecycle unavailable: %s", e)
        return None

    target = _normalise(pname)
    if not target or len(target) < 4:
        return None

    matches = []
    for row in rows:
        rn = _normalise(row["name"])
        if not rn:
            continue
        # Require the LISTING name to be contained in the product name —
        # one direction only. The old bidirectional check also matched
        # `target in rn`, which let a short product name like "Edge"
        # (normalised from "Microsoft Edge", "microsoft" stripped) match
        # the unrelated listing "Azure Stack Edge" → false EOL (tester
        # screenshot: evergreen Edge browser flagged EOL 2024-03-31).
        # "rn in target" keeps the legit cases: listing "SQL Server 2014"
        # ⊂ product "Microsoft SQL Server 2014 Management Objects"; an
        # exact-equal name is also covered (rn == target ⇒ rn in target).
        if rn in target:
            matches.append(row)
    if not matches:
        return None

    # Prefer the latest NON-ESU end date: paid Extended Security Updates are
    # an add-on most hosts don't have, so security-conservatively a product
    # is EOL when its standard (extended) support ends, not when the
    # purchasable ESU window closes. Fall back to the overall max only if
    # every matching row is an ESU row.
    def _is_esu(r: dict) -> bool:
        rel = (r.get("release") or "").lower()
        return "extended security update" in rel or "esu" in rel

    non_esu = [r for r in matches if not _is_esu(r)]
    best = max(non_esu or matches, key=lambda r: r["end_date"])
    st = _status_from_end_date(
        display_name=best["name"], release=best["release"],
        end_iso=best["end_date"], installed_version=installed_version,
    )
    if st.is_eol or st.is_eol_soon:
        return st
    return None
