"""Append-only hash-chained audit log (``docs/07-risk-controls.md``).

Every line is::

    <iso-ts>|<event>|<json-payload>|prev_hash=<hex>|hash=<hex>

``hash`` is ``sha256("<iso-ts>|<event>|<json-payload>|<prev_hash>")`` so
that the integrity of the file can be re-verified by walking the chain
top-to-bottom. The first line uses ``prev_hash="0" * 64``.

The writer ``flush + os.fsync`` after every append; for the audit trail,
durability beats throughput.
"""

from __future__ import annotations

import hashlib
import json
import os
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

__all__ = [
    "GENESIS_HASH",
    "AuditChainError",
    "AuditEntry",
    "AuditLog",
    "tail_continues_from",
    "verify_chain",
]


GENESIS_HASH = "0" * 64
_SEP = "|"


class AuditChainError(RuntimeError):
    """Raised when the audit chain fails verification."""


@dataclass(frozen=True)
class AuditEntry:
    """Parsed audit-log line."""

    timestamp: datetime
    event: str
    payload: dict[str, Any]
    prev_hash: str
    hash: str


def _canonical_payload(payload: dict[str, Any]) -> str:
    """Serialize the payload deterministically (sorted keys, no whitespace)."""
    return json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str)


def _compute_hash(timestamp: str, event: str, payload_json: str, prev_hash: str) -> str:
    raw = f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}{prev_hash}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()


def _format_line(
    timestamp: str, event: str, payload_json: str, prev_hash: str, line_hash: str
) -> str:
    return (
        f"{timestamp}{_SEP}{event}{_SEP}{payload_json}{_SEP}"
        f"prev_hash={prev_hash}{_SEP}hash={line_hash}\n"
    )


def _parse_line(line: str) -> AuditEntry:
    """Parse a stored audit line back into :class:`AuditEntry`.

    The payload may legitimately contain ``|`` characters inside JSON
    strings; we therefore split from the right for the two trailing
    fields and from the left for the timestamp + event + payload.
    """
    if not line.endswith("\n"):
        line = line + "\n"
    body = line.rstrip("\n")

    # Trailing parts.
    try:
        rest, hash_part = body.rsplit(_SEP, 1)
    except ValueError as exc:
        raise AuditChainError("missing hash= field") from exc
    if not hash_part.startswith("hash="):
        raise AuditChainError("missing hash= field")
    line_hash = hash_part[len("hash=") :]

    try:
        rest, prev_part = rest.rsplit(_SEP, 1)
    except ValueError as exc:
        raise AuditChainError("missing prev_hash= field") from exc
    if not prev_part.startswith("prev_hash="):
        raise AuditChainError("missing prev_hash= field")
    prev_hash = prev_part[len("prev_hash=") :]

    # Leading parts.
    parts = rest.split(_SEP, 2)
    if len(parts) != 3:
        raise AuditChainError("malformed leading section")
    ts_str, event, payload_json = parts

    try:
        payload: dict[str, Any] = json.loads(payload_json)
    except json.JSONDecodeError as exc:
        raise AuditChainError("payload is not valid JSON") from exc
    if not isinstance(payload, dict):
        raise AuditChainError("payload must be a JSON object")

    return AuditEntry(
        timestamp=datetime.fromisoformat(ts_str),
        event=event,
        payload=payload,
        prev_hash=prev_hash,
        hash=line_hash,
    )


def verify_chain(path: str | Path) -> int:
    """Re-walk *path* and raise :class:`AuditChainError` on tampering.

    Returns the number of lines verified (0 if the file does not exist
    or is empty).
    """
    p = Path(path)
    if not p.exists() or p.stat().st_size == 0:
        return 0
    expected_prev = GENESIS_HASH
    count = 0
    with p.open("r", encoding="utf-8") as fh:
        for lineno, line in enumerate(fh, start=1):
            if not line.strip():
                continue
            entry = _parse_line(line)
            if entry.prev_hash != expected_prev:
                raise AuditChainError(
                    f"line {lineno}: prev_hash mismatch "
                    f"(expected {expected_prev}, got {entry.prev_hash})"
                )
            recomputed = _compute_hash(
                entry.timestamp.isoformat(),
                entry.event,
                _canonical_payload(entry.payload),
                entry.prev_hash,
            )
            if recomputed != entry.hash:
                raise AuditChainError(
                    f"line {lineno}: hash mismatch (expected {recomputed}, "
                    f"got {entry.hash})"
                )
            expected_prev = entry.hash
            count += 1
    return count


def tail_continues_from(path: str | Path, anchor_hash: str) -> bool:
    """Return ``True`` when *anchor_hash* is a genuine ancestor of the tail.

    That is: *anchor_hash* is the ``hash`` of some line in the log AND every
    line after it forms a valid forward chain to EOF. This distinguishes a
    **benign anchor lag** — the best-effort anchor persisted in
    :func:`cerbero_bite.runtime.dependencies.build_runtime` fell behind the
    file under SQLite write contention, yet the log grew forward
    legitimately — from real **truncation/tampering** (anchor absent, or the
    post-anchor chain broken).

    Returns ``False`` if the file is missing/empty, the anchor is not found,
    or the chain from the anchor to the tail does not verify. The single-
    writer invariant of :class:`AuditLog` still holds; this only makes the
    boot-time anchor check tolerant of the durability gap it documents.
    """
    p = Path(path)
    if not p.exists() or p.stat().st_size == 0:
        return False
    seen = False
    expected_prev = ""
    with p.open("r", encoding="utf-8") as fh:
        for line in fh:
            if not line.strip():
                continue
            try:
                entry = _parse_line(line)
            except AuditChainError:
                if seen:
                    return False
                continue
            if seen:
                if entry.prev_hash != expected_prev:
                    return False
                recomputed = _compute_hash(
                    entry.timestamp.isoformat(),
                    entry.event,
                    _canonical_payload(entry.payload),
                    entry.prev_hash,
                )
                if recomputed != entry.hash:
                    return False
                expected_prev = entry.hash
            elif entry.hash == anchor_hash:
                seen = True
                expected_prev = entry.hash
    return seen


def iter_entries(path: str | Path) -> Iterator[AuditEntry]:
    """Yield each :class:`AuditEntry` from *path* without verifying."""
    p = Path(path)
    if not p.exists():
        return
    with p.open("r", encoding="utf-8") as fh:
        for line in fh:
            if line.strip():
                yield _parse_line(line)


class AuditLog:
    """Writer for the hash-chained audit log.

    A single instance per process is enough; concurrent writers are not
    supported by design (the engine is the only writer). ``append`` is
    fsync'd before returning.
    """

    def __init__(
        self,
        path: str | Path,
        *,
        on_append: Callable[[str], None] | None = None,
    ) -> None:
        self._path = Path(path)
        self._path.parent.mkdir(parents=True, exist_ok=True)
        self._last_hash: str = self._tail_hash() or GENESIS_HASH
        self._on_append = on_append

    @property
    def path(self) -> Path:  # pragma: no cover — accessor used by callers only
        return self._path

    @property
    def last_hash(self) -> str:
        return self._last_hash

    def _tail_hash(self) -> str | None:
        if not self._path.exists() or self._path.stat().st_size == 0:
            return None
        # Walk from EOF to find the last non-empty line. The chunked
        # back-seek covers files larger than 4 KiB; the loop-exhausted
        # branch is reached only when a partial / no-newline file is
        # encountered (defensive — :func:`append` always writes "\n").
        with self._path.open("rb") as fh:
            fh.seek(0, os.SEEK_END)
            size = fh.tell()
            buf = b""
            offset = size
            chunk = 4096
            while offset > 0:  # pragma: no branch — terminates via break or offset==0
                read = min(chunk, offset)
                offset -= read
                fh.seek(offset)
                buf = fh.read(read) + buf
                if b"\n" in buf:
                    break
            text = buf.decode("utf-8", errors="strict")
        for line in reversed(text.splitlines()):  # pragma: no branch
            if line.strip():
                entry = _parse_line(line)
                return entry.hash
        return None  # pragma: no cover — only hit when file is all blank lines

    def append(
        self,
        *,
        event: str,
        payload: dict[str, Any] | None = None,
        now: datetime | None = None,
    ) -> AuditEntry:
        """Append one event line and return the resulting entry."""
        ts = (now or datetime.now(UTC)).astimezone(UTC)
        ts_iso = ts.isoformat()
        payload_json = _canonical_payload(payload or {})
        prev_hash = self._last_hash
        line_hash = _compute_hash(ts_iso, event, payload_json, prev_hash)

        line = _format_line(ts_iso, event, payload_json, prev_hash, line_hash)
        with self._path.open("a", encoding="utf-8") as fh:
            fh.write(line)
            fh.flush()
            os.fsync(fh.fileno())

        self._last_hash = line_hash
        if self._on_append is not None:
            self._on_append(line_hash)
        return AuditEntry(
            timestamp=ts,
            event=event,
            payload=dict(payload or {}),
            prev_hash=prev_hash,
            hash=line_hash,
        )
