"""Differential integrity — suppress known findings, surface only new ones.

Implements Osquery-style result-set diffing for ``census integrity``:
store the previous integrity report as a state file, compare against the
next run, and only surface findings that are **new or changed**.

Each finding tracks ``first_seen`` (ISO-8601) and ``occurrences`` (consecutive
run count) — forensic metadata no competitor (AIDE, Tripwire, Wazuh, Osquery,
Samhain) provides at the per-finding level.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path

logger = logging.getLogger(__name__)

from .evidence import IntegrityReport

SCHEMA_VERSION = 1
MAX_STATE_SIZE = 50 * 1024 * 1024  # 50 MB safety limit


@dataclass
class FindingState:
    """Per-finding metadata carried across runs."""

    status: str
    fingerprint: str
    first_seen: str
    occurrences: int = 1


@dataclass
class IntegrityState:
    """Snapshot of integrity findings at a point in time."""

    schema_version: int = SCHEMA_VERSION
    census_version: str = ""
    root_dir: str = ""
    timestamp: str = ""
    findings: dict[str, FindingState] = field(default_factory=dict)


@dataclass
class DifferentialMeta:
    """Statistics about the differential filtering pass."""

    total_current: int = 0
    suppressed: int = 0
    new_findings: int = 0


def _fingerprint_for(status: str, actual_hash: str = "") -> str:
    """Compute a stable fingerprint for deduplication.

    - ``modified``: uses ``actual_hash`` (content-aware, resurfaces on change).
    - ``missing``: fixed ``"missing"`` (path-only — file is gone either way).
    - ``new``: path-only (empty string) because ``integrity_check`` does not
      hash new files.  A content change in a "new" file won't resurface it.
      Run ``census update`` to accept new files into the baseline promptly.
    """
    if status == "missing":
        return "missing"
    return actual_hash


def build_state(
    report: IntegrityReport,
    *,
    previous: IntegrityState | None = None,
) -> IntegrityState:
    """Build a state snapshot from a full integrity report.

    If *previous* is given, ``first_seen`` and ``occurrences`` are carried
    forward for findings that persist with the same fingerprint.
    """
    from . import __version__

    now = time.strftime("%Y-%m-%dT%H:%M:%S%z")
    state = IntegrityState(
        census_version=__version__,
        root_dir=report.root_dir,
        timestamp=now,
    )

    prev_findings = previous.findings if previous else {}

    for mod in report.modified:
        fp = _fingerprint_for("modified", mod.actual_hash)
        prev = prev_findings.get(mod.path)
        if prev and prev.status == "modified" and prev.fingerprint == fp:
            state.findings[mod.path] = FindingState(
                status="modified",
                fingerprint=fp,
                first_seen=prev.first_seen,
                occurrences=prev.occurrences + 1,
            )
        else:
            state.findings[mod.path] = FindingState(
                status="modified", fingerprint=fp, first_seen=now,
            )

    for path_str in report.missing:
        fp = _fingerprint_for("missing")
        prev = prev_findings.get(path_str)
        if prev and prev.status == "missing" and prev.fingerprint == fp:
            state.findings[path_str] = FindingState(
                status="missing",
                fingerprint=fp,
                first_seen=prev.first_seen,
                occurrences=prev.occurrences + 1,
            )
        else:
            state.findings[path_str] = FindingState(
                status="missing", fingerprint=fp, first_seen=now,
            )

    for path_str in report.new_files:
        fp = _fingerprint_for("new", "")
        prev = prev_findings.get(path_str)
        if prev and prev.status == "new" and prev.fingerprint == fp:
            state.findings[path_str] = FindingState(
                status="new",
                fingerprint=fp,
                first_seen=prev.first_seen,
                occurrences=prev.occurrences + 1,
            )
        else:
            state.findings[path_str] = FindingState(
                status="new", fingerprint=fp, first_seen=now,
            )

    return state


def filter_report(
    report: IntegrityReport,
    previous: IntegrityState,
) -> tuple[IntegrityReport, DifferentialMeta]:
    """Remove findings already present in *previous* with the same fingerprint.

    Returns a **new** report (the original is not mutated) and differential
    statistics.
    """
    prev = previous.findings
    meta = DifferentialMeta()

    filtered = IntegrityReport(
        root_dir=report.root_dir,
        total_in_manifest=report.total_in_manifest,
        total_checked=report.total_checked,
        unchanged=report.unchanged,
        errors=list(report.errors),
    )

    for mod in report.modified:
        meta.total_current += 1
        fp = _fingerprint_for("modified", mod.actual_hash)
        p = prev.get(mod.path)
        if p and p.status == "modified" and p.fingerprint == fp:
            meta.suppressed += 1
        else:
            filtered.modified.append(mod)
            meta.new_findings += 1

    for path_str in report.missing:
        meta.total_current += 1
        fp = _fingerprint_for("missing")
        p = prev.get(path_str)
        if p and p.status == "missing" and p.fingerprint == fp:
            meta.suppressed += 1
        else:
            filtered.missing.append(path_str)
            meta.new_findings += 1

    for path_str in report.new_files:
        meta.total_current += 1
        fp = _fingerprint_for("new", "")
        p = prev.get(path_str)
        if p and p.status == "new" and p.fingerprint == fp:
            meta.suppressed += 1
        else:
            filtered.new_files.append(path_str)
            meta.new_findings += 1

    return filtered, meta


def load_state(path: Path) -> IntegrityState:
    """Load a state file from disk.

    Raises ``ValueError`` on schema mismatch or malformed JSON.
    Raises ``OSError`` on I/O failure.
    """
    size = path.stat().st_size
    if size > MAX_STATE_SIZE:
        raise ValueError(
            f"State file too large ({size:,} bytes, limit {MAX_STATE_SIZE:,})"
        )

    raw = json.loads(path.read_text(encoding="utf-8"))

    if not isinstance(raw, dict):
        raise ValueError("State file is not a JSON object")

    sv = raw.get("schema_version")
    if sv != SCHEMA_VERSION:
        raise ValueError(
            f"Unsupported state schema version {sv} (expected {SCHEMA_VERSION})"
        )

    findings_raw = raw.get("findings", {})
    if not isinstance(findings_raw, dict):
        raise ValueError("State file 'findings' is not a JSON object")

    findings: dict[str, FindingState] = {}
    for rel_path, fdata in findings_raw.items():
        if not isinstance(fdata, dict):
            raise ValueError(f"Finding '{rel_path}' is not a JSON object")
        try:
            findings[rel_path] = FindingState(
                status=fdata["status"],
                fingerprint=fdata["fingerprint"],
                first_seen=fdata["first_seen"],
                occurrences=fdata.get("occurrences", 1),
            )
        except KeyError as exc:
            raise ValueError(
                f"Finding '{rel_path}' missing required field: {exc}"
            ) from exc

    return IntegrityState(
        schema_version=sv,
        census_version=raw.get("census_version", ""),
        root_dir=raw.get("root_dir", ""),
        timestamp=raw.get("timestamp", ""),
        findings=findings,
    )


def save_state(path: Path, state: IntegrityState) -> None:
    """Write *state* to *path* atomically (temp file + rename).

    The temp file is created in the same directory as *path* to ensure
    the rename is atomic (same filesystem).
    """
    data = {
        "schema_version": state.schema_version,
        "census_version": state.census_version,
        "root_dir": state.root_dir,
        "timestamp": state.timestamp,
        "findings": {
            k: asdict(v) for k, v in state.findings.items()
        },
    }
    content = json.dumps(data, indent=2, default=str) + "\n"

    parent = path.parent
    parent.mkdir(parents=True, exist_ok=True)

    fd, tmp = tempfile.mkstemp(dir=str(parent), suffix=".tmp")
    closed = False
    try:
        os.write(fd, content.encode("utf-8"))
        os.close(fd)
        closed = True
        os.replace(tmp, str(path))
    except BaseException:
        if not closed:
            os.close(fd)
        try:
            os.unlink(tmp)
        except OSError as exc:
            logger.warning("Cannot remove temporary state file %s: %s", tmp, exc)
        raise


def default_state_path(manifest_path: Path) -> Path:
    """Derive the default sidecar state path from a manifest path.

    Convention: ``manifest.db`` → ``manifest.db.integrity-state``
    (follows the ``.seal`` sidecar pattern from ``seal.py``).
    """
    return manifest_path.with_suffix(manifest_path.suffix + ".integrity-state")
