"""Phase D: Audit I/O — HMAC signing, rotation, chain verification for plain JSONL.

Used by BuiltinBridge when ForensicLedger is unavailable.
Phase D2: Hash chain — cid = SHA256(canonical_bytes + ":" + prev_cid).
"""

from __future__ import annotations

import datetime
import hashlib
import hmac
import json
import os
from pathlib import Path
from typing import Any

CHAIN_GENESIS = "genesis"


def _canonical_json(entry: dict[str, Any]) -> str:
    """Deterministic JSON for hashing. sort_keys, no whitespace."""
    return json.dumps(entry, separators=(",", ":"), ensure_ascii=False, sort_keys=True)


def _compute_hmac(entry: dict[str, Any], key: bytes) -> str:
    """Compute HMAC-SHA256 over canonical JSON (sort_keys=True). Returns hex."""
    payload = json.dumps(entry, separators=(",", ":"), ensure_ascii=False, sort_keys=True)
    sig = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return sig


def _maybe_rotate(path: Path, max_mb: int) -> None:
    """Rotate log if size exceeds max_mb. audit.jsonl → .1, .1 → .2, etc.
    Keeps up to 10 rotated files. Never deletes — operator's responsibility."""
    if max_mb <= 0:
        return
    try:
        size_mb = path.stat().st_size / (1024 * 1024)
    except OSError:
        return
    if size_mb < max_mb:
        return
    max_rotated = 10
    # Rotate: .9 → .10 (delete .10), .8 → .9, ..., .1 → .2, path → .1
    for i in range(max_rotated, 0, -1):
        old = path if i == 1 else Path(f"{path}.{i - 1}")
        new = Path(f"{path}.{i}")
        if old.exists():
            try:
                if new.exists():
                    new.unlink()
                old.rename(new)
            except OSError:
                pass
    # path is now free; next write creates it fresh


def append_audit_entry(
    path: str | Path,
    entry: dict[str, Any],
    *,
    signing_key: bytes | None = None,
    max_mb: int = 0,
    prev_cid: str | None = None,
) -> str | None:
    """Append one audit entry. Optionally sign with HMAC, rotate if over max_mb.
    When prev_cid is provided (D2 chain): compute cid, add prev_cid and cid to entry.
    Returns the new cid when prev_cid was provided, else None."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    if max_mb > 0 and p.exists():
        _maybe_rotate(p, max_mb)
    entry = dict(entry)
    if prev_cid is not None:
        entry_bytes = _canonical_json(entry)
        cid = hashlib.sha256((entry_bytes + ":" + prev_cid).encode()).hexdigest()
        entry["prev_cid"] = prev_cid
        entry["cid"] = cid
    if signing_key:
        entry["hmac"] = _compute_hmac(entry, signing_key)
    line = _canonical_json(entry) + "\n"
    with open(p, "a", encoding="utf-8") as f:
        f.write(line)
    return entry.get("cid") if prev_cid is not None else None


def append_checkpoint_entry(
    path: str | Path,
    prev_cid: str,
    *,
    signing_key: bytes | None = None,
    max_mb: int = 0,
) -> str | None:
    """D3 Option 2: Append a checkpoint entry attesting to chain tip at current time.
    checkpoint_sig = HMAC({"checkpoint_cid": prev_cid, "checkpoint_time": ...}, key).
    Returns the new cid."""
    p = Path(path)
    p.parent.mkdir(parents=True, exist_ok=True)
    if max_mb > 0 and p.exists():
        _maybe_rotate(p, max_mb)
    checkpoint_time = datetime.datetime.now(datetime.timezone.utc).isoformat()
    entry: dict[str, Any] = {
        "type": "checkpoint",
        "checkpoint_cid": prev_cid,
        "checkpoint_time": checkpoint_time,
    }
    entry_bytes = _canonical_json(entry)
    cid = hashlib.sha256((entry_bytes + ":" + prev_cid).encode()).hexdigest()
    entry["prev_cid"] = prev_cid
    entry["cid"] = cid
    if signing_key:
        sig_payload = json.dumps(
            {"checkpoint_cid": prev_cid, "checkpoint_time": checkpoint_time},
            separators=(",", ":"),
            sort_keys=True,
        )
        entry["checkpoint_sig"] = hmac.new(
            signing_key, sig_payload.encode("utf-8"), hashlib.sha256
        ).hexdigest()
    line = _canonical_json(entry) + "\n"
    with open(p, "a", encoding="utf-8") as f:
        f.write(line)
    return cid


def verify_audit_entries(
    path: str | Path,
    n: int,
    signing_key: bytes | None = None,
    *,
    signing_keys: list[bytes] | None = None,
) -> tuple[bool, int, str | None]:
    """Verify last n entries. Returns (verified, entries_checked, first_failed_cid).
    first_failed_cid is the stored cid of the first entry that fails verification
    (usable directly with /v1/audit/entry?cid=...).  Falls back to trace_id if
    cid is absent.

    D4 multi-key: pass signing_keys (list) to verify logs spanning key rotation.
    Tries each key per entry until one succeeds. signing_key (single) still supported."""
    keys = list(signing_keys) if signing_keys else ([signing_key] if signing_key else [])
    p = Path(path)
    if not p.exists():
        return True, 0, None
    try:
        lines = p.read_text(encoding="utf-8").strip().splitlines()
    except OSError:
        return False, 0, None
    lines = [ln for ln in lines if ln.strip()][-n:]
    for i, ln in enumerate(lines):
        try:
            entry = json.loads(ln)
        except json.JSONDecodeError:
            return False, i, None
        if entry.get("type") == "checkpoint":
            if "checkpoint_sig" not in entry:
                return False, i, None
            sig_payload = json.dumps(
                {"checkpoint_cid": entry["checkpoint_cid"], "checkpoint_time": entry["checkpoint_time"]},
                separators=(",", ":"),
                sort_keys=True,
            )
            expected = entry.pop("checkpoint_sig")
            matched = False
            for key in keys:
                if not key:
                    continue
                actual = hmac.new(key, sig_payload.encode("utf-8"), hashlib.sha256).hexdigest()
                if hmac.compare_digest(expected, actual):
                    matched = True
                    break
            entry["checkpoint_sig"] = expected
            if not matched:
                return False, i + 1, None
            continue
        if "hmac" not in entry:
            return False, i, entry.get("cid") or entry.get("trace_id")
        expected = entry.pop("hmac")
        payload = _canonical_json(entry)
        matched = False
        for key in keys:
            if not key:
                continue
            actual = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
            if hmac.compare_digest(expected, actual):
                matched = True
                break
        if not matched:
            entry["hmac"] = expected
            return False, i + 1, entry.get("cid") or entry.get("trace_id")
        entry["hmac"] = expected  # restore for caller if needed
    return True, len(lines), None


def verify_chain(
    path: str | Path,
    n: int,
    signing_key: bytes | None = None,
    *,
    signing_keys: list[bytes] | None = None,
) -> tuple[bool, int, int | None, str | None]:
    """Verify hash chain on last n entries (D2/D3).
    Returns (ok, entries_checked, first_break_index, reason).
    first_break_index is 0-based; reason is 'chain_break', 'hmac_failed', or
    'unanchored_slice' (internally consistent but not anchored to genesis).

    D4 multi-key: pass signing_keys (list) to verify logs spanning key rotation.
    Tries each key per entry until one succeeds. signing_key (single) still supported."""
    keys = list(signing_keys) if signing_keys else ([signing_key] if signing_key else [])
    p = Path(path)
    if not p.exists():
        return True, 0, None, None
    try:
        lines = p.read_text(encoding="utf-8").strip().splitlines()
    except OSError:
        return False, 0, 0, "read_error"
    lines = [ln for ln in lines if ln.strip()][-n:]
    prev_cid = CHAIN_GENESIS
    _unanchored = False   # True when first chained entry links outside this window
    _seen_chained = False  # True once we've accepted the first chained entry
    for i, ln in enumerate(lines):
        try:
            entry = json.loads(ln)
        except json.JSONDecodeError:
            return False, i, i, "json_error"
        if "cid" not in entry or "prev_cid" not in entry:
            # Non-chained entries (stream_abort, forensic_envelope, etc.) are
            # transparent to chain verification — skip without resetting prev_cid.
            continue
        if entry.get("prev_cid") != prev_cid:
            if not _seen_chained and prev_cid == CHAIN_GENESIS:
                # First chained entry in the window has a non-genesis prev_cid.
                # This is a tail-slice: the window starts mid-chain.  Accept this
                # entry's claimed cid as the local anchor and verify internal
                # continuity for all subsequent chained entries.
                _unanchored = True
                prev_cid = entry["cid"]
                _seen_chained = True
                continue
            return False, len(lines), i, "chain_break"
        # cid is computed before hmac/checkpoint_sig; exclude cid, prev_cid, hmac, checkpoint_sig
        exclude = ("cid", "prev_cid", "hmac", "checkpoint_sig")
        entry_for_cid = {k: v for k, v in entry.items() if k not in exclude}
        entry_bytes = _canonical_json(entry_for_cid)
        computed = hashlib.sha256((entry_bytes + ":" + prev_cid).encode()).hexdigest()
        if not hmac.compare_digest(computed, entry["cid"]):
            return False, len(lines), i, "chain_break"
        if entry.get("type") == "checkpoint" and keys and "checkpoint_sig" in entry:
            expected = entry.pop("checkpoint_sig")
            sig_payload = json.dumps(
                {"checkpoint_cid": entry["checkpoint_cid"], "checkpoint_time": entry["checkpoint_time"]},
                separators=(",", ":"),
                sort_keys=True,
            )
            matched = False
            for key in keys:
                if not key:
                    continue
                actual = hmac.new(key, sig_payload.encode("utf-8"), hashlib.sha256).hexdigest()
                if hmac.compare_digest(expected, actual):
                    matched = True
                    break
            entry["checkpoint_sig"] = expected
            if not matched:
                return False, len(lines), i, "hmac_failed"
            prev_cid = entry["cid"]
            _seen_chained = True
            continue
        if keys and "hmac" in entry:
            expected = entry.pop("hmac")
            payload = _canonical_json(entry)
            matched = False
            for key in keys:
                if not key:
                    continue
                actual = hmac.new(key, payload.encode("utf-8"), hashlib.sha256).hexdigest()
                if hmac.compare_digest(expected, actual):
                    matched = True
                    break
            entry["hmac"] = expected
            if not matched:
                return False, len(lines), i, "hmac_failed"
        prev_cid = entry["cid"]
        _seen_chained = True
    if _unanchored:
        # Slice is internally consistent but not anchored to the chain head.
        return False, len(lines), None, "unanchored_slice"
    return True, len(lines), None, None


def _recompute_state_hash(pef_snapshot: dict[str, Any]) -> str:
    """Recompute state_hash from pef_snapshot (matches build_forensic_event)."""
    return hashlib.sha256(
        json.dumps(pef_snapshot, separators=(",", ":"), sort_keys=True).encode()
    ).hexdigest()


def verify_forensic_state_hash(
    path: str | Path,
    n: int = 1000,
) -> tuple[bool, int, int | None, str | None]:
    """Replay-verify state_hash: recompute from pef_snapshot, assert matches forensic_event.

    For each audit entry with forensic_event and pef_snapshot, recomputes state_hash
    from the stored pef_snapshot and asserts it matches forensic_event[\"state_hash\"].

    Returns (ok, entries_checked, first_failed_index, reason).
    first_failed_index is 0-based; reason is 'state_hash_mismatch' or 'missing_pef_snapshot'.
    Skips entries without forensic_event, checkpoint entries, and entries where
    forensic_event.state_hash is None (e.g. extraction failed before PEF).
    """
    p = Path(path)
    if not p.exists():
        return True, 0, None, None
    try:
        lines = p.read_text(encoding="utf-8").strip().splitlines()
    except OSError:
        return False, 0, 0, "read_error"
    lines = [ln for ln in lines if ln.strip()][-n:]
    checked = 0
    for i, ln in enumerate(lines):
        try:
            entry = json.loads(ln)
        except json.JSONDecodeError:
            return False, checked, i, "json_error"
        if entry.get("type") == "checkpoint":
            continue
        fe = entry.get("forensic_event")
        if fe is None:
            continue
        expected_hash = fe.get("state_hash")
        if expected_hash is None:
            continue
        pef_snapshot = entry.get("pef_snapshot")
        if pef_snapshot is None:
            return False, checked + 1, i, "missing_pef_snapshot"
        computed = _recompute_state_hash(pef_snapshot)
        if not hmac.compare_digest(computed, expected_hash):
            return False, checked + 1, i, "state_hash_mismatch"
        checked += 1
    return True, checked, None, None
