"""QML-facing license model: grid rows, Load License, replay trial marker."""

import logging
import shutil
import time
from pathlib import Path

from PySide6.QtCore import Property, QObject, Signal, Slot

from pygui.backend.license.license_manager import (
    LICENSE_FILENAME_RE,
    License,
    parse_license_blob,
    read_license_files,
)
from pygui.backend.utils import to_local_path as _to_local_path

log = logging.getLogger(__name__)

TRIAL_DAYS = 30


# ===== License Model =====
class LicenseModel(QObject):
    """Context property backing the About dialog license grid.

    Licenses live in ``<data_dir>/licenses`` (mirrors C#'s
    ``DataDir\\licenses``). The 30-day replay trial marker is
    ``<data_dir>/replay.lic``. "Removed" licenses are tracked in
    ``<data_dir>/removed_licenses.txt`` (one ``serial-level`` per line) —
    removal hides a license from the app without deleting its ``.bin``,
    so the vendor-issued file always stays recoverable on disk.
    """

    licensesChanged = Signal()

    def __init__(self, data_dir: str, parent: QObject | None = None) -> None:
        super().__init__(parent)
        self._data_dir = Path(data_dir)
        self._licenses_dir = self._data_dir / "licenses"
        try:
            self._licenses_dir.mkdir(parents=True, exist_ok=True)
        except OSError as exc:
            log.warning("Cannot create licenses dir %s: %s", self._licenses_dir, exc)
        self._licenses: list[License] = []
        self.refresh()

    # ── Grid rows ─────────────────────────────────────────────────────
    @Property("QVariantList", notify=licensesChanged)  # type: ignore[arg-type]
    def licenses(self) -> list:
        """Rows for the About grid: serial, mfg date, level, license date."""
        return [
            {
                "serial": lic.serial,
                "mfgDate": lic.mfg_date_decimal,
                "level": lic.level,
                "licDate": lic.lic_date_decimal,
            }
            for lic in self._licenses
        ]

    @Slot()
    def refresh(self) -> None:
        """Re-scan the licenses directory, filtering out removed entries."""
        removed = self._read_removed()
        self._licenses = [
            lic for lic in read_license_files(self._licenses_dir)
            if f"{lic.serial}-{lic.level}" not in removed
        ]
        self.licensesChanged.emit()

    # ── Load License ──────────────────────────────────────────────────
    @Slot(str, result=bool)
    def loadLicenseFile(self, path_or_url: str) -> bool:
        """Validate a picked .bin and copy it into the licenses directory."""
        src = _to_local_path(path_or_url)
        if not src.is_file():
            log.warning("License file does not exist: %s", src)
            return False
        if not LICENSE_FILENAME_RE.match(src.name):
            log.warning("License filename invalid: %s", src.name)
            return False

        try:
            blob = src.read_bytes()
        except OSError as exc:
            log.warning("Cannot read license file %s: %s", src, exc)
            return False

        lic = parse_license_blob(blob)
        if lic is None or src.name != f"{lic.serial}-{lic.level}.bin":
            log.warning("License file failed validation: %s", src.name)
            return False

        # Skip the copy when src already IS the file in place — e.g. re-picking
        # a previously-removed license from <data_dir>/licenses/ to un-hide it.
        # shutil.copyfile raises SameFileError (an OSError) on src == dst.
        try:
            self._licenses_dir.mkdir(parents=True, exist_ok=True)
            dst = self._licenses_dir / src.name
            if src.resolve() != dst.resolve():
                shutil.copyfile(src, dst)
        except OSError as exc:
            log.warning("Cannot copy license into %s: %s", self._licenses_dir, exc)
            return False

        # An explicit (re)load un-hides the license if it was previously
        # removed from the app view — the user is deliberately bringing it back.
        self._discard_removed(lic.serial, lic.level)
        self.refresh()
        return True

    # ── Remove License (app-side only — .bin stays on disk) ────────────
    @property
    def _removed_marker(self) -> Path:
        return self._data_dir / "removed_licenses.txt"

    def _read_removed(self) -> set[str]:
        try:
            return set(self._removed_marker.read_text().splitlines())
        except OSError:
            return set()

    def _discard_removed(self, serial: str, level: str) -> None:
        entry = f"{serial}-{level}"
        removed = self._read_removed()
        if entry not in removed:
            return
        removed.discard(entry)
        try:
            self._removed_marker.write_text("\n".join(sorted(removed)) + "\n" if removed else "")
        except OSError as exc:
            log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc)

    @Slot(str, str, result=bool)
    def removeLicense(self, serial: str, level: str) -> bool:
        """Hide license (serial, level) from the app. Leaves the .bin on disk."""
        if not any(lic.serial == serial and lic.level == level for lic in self._licenses):
            return False

        entry = f"{serial}-{level}"
        removed = self._read_removed()
        removed.add(entry)
        try:
            self._removed_marker.write_text("\n".join(sorted(removed)) + "\n")
        except OSError as exc:
            log.warning("Cannot update removed-license marker %s: %s", self._removed_marker, exc)
            return False

        self.refresh()
        return True

    # ── Wafer detect (informational) ──────────────────────────────────
    @Slot(str, result=str)
    def levelForWafer(self, serial: str) -> str:
        """License level for a wafer serial like "A00001"; "" if unlicensed."""
        for lic in self._licenses:
            if lic.serial == serial:
                return lic.level
        return ""

    # ── Replay trial (30-day marker, C# parity) ───────────────────────
    @property
    def _trial_marker(self) -> Path:
        return self._data_dir / "replay.lic"

    @Slot(result=str)
    def replayLicenseState(self) -> str:
        """"permanent" if any license level >= 1, "temporary" if trial
        marker exists, else "none" (trial not started)."""
        if any(lic.level_int >= 1 for lic in self._licenses):
            return "permanent"
        if self._trial_marker.exists():
            return "temporary"
        return "none"

    @Slot(result=bool)
    def startReplayTrial(self) -> bool:
        """Create the trial marker if absent. False on I/O failure.

        Must never touch an existing marker: Path.touch(exist_ok=True) bumps
        mtime even when the file is already there, which would silently push
        the 30-day clock forward every time this is called on an
        already-started trial — an infinite-reset hole.
        """
        if self._trial_marker.exists():
            return True
        try:
            self._trial_marker.touch()
            return True
        except OSError as exc:
            log.warning("Cannot create trial marker: %s", exc)
            return False

    @Slot(result=bool)
    def replayTrialExpired(self) -> bool:
        """True once the marker is older than 30 days (or missing)."""
        marker = self._trial_marker
        if not marker.exists():
            return True
        age_days = (time.time() - marker.stat().st_mtime) / 86400.0
        return age_days > TRIAL_DAYS

    @Slot(result=bool)
    def reviewAccessBlocked(self) -> bool:
        """True only once a started trial has actually expired (ADR-0005).

        A "none" state (trial never started) is not a block — nothing to
        enforce yet. "permanent" (a real license) never blocks.
        """
        return self.replayLicenseState() == "temporary" and self.replayTrialExpired()
