"""Hash-chained append-only storage for Topic-59 publication history."""

from __future__ import annotations

import fcntl
import hashlib
import json
import os
from collections.abc import Generator
from contextlib import contextmanager
from pathlib import Path
from typing import ClassVar, Final, Literal, TextIO

from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from pydantic_core import PydanticCustomError

from .nutrition_weekly_operations_identifiers import TOPIC59_DIGEST_PATTERN, is_kst_iso_day, topic59_payload_is_safe
from .nutrition_weekly_operations_models import Topic59Projection
from .nutrition_weekly_operations_publication_contract import (
    Topic59IncidentReason,
    Topic59LedgerCorruption,
    Topic59LedgerState,
    Topic59ProviderFailure,
)


_LEDGER_SCHEMA: Final = "nutricoach-topic59-publication-ledger-v2"
_ZERO_DIGEST: Final = "0" * 64


class Topic59LedgerEntry(BaseModel):
    model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True, extra="forbid", populate_by_name=True, strict=True)

    schema_version: Literal["nutricoach-topic59-publication-ledger-v2"] = Field(alias="schema", serialization_alias="schema")
    sequence: int = Field(ge=1)
    predecessor_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    entry_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    card_slot: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    logical_key: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    customer_identity_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    kst_day: str
    config_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    candidate_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    route_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    status_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    payload_digest: str = Field(pattern=TOPIC59_DIGEST_PATTERN)
    operation: Literal["send", "edit"]
    state: Topic59LedgerState
    payload: dict[str, str | int]
    message_id: str | None = Field(default=None, pattern=r"^[0-9]{1,19}$")
    provider_reason: Topic59ProviderFailure | None = None
    incident_reason: Topic59IncidentReason | None = None

    @model_validator(mode="after")
    def _validate_values(self) -> Topic59LedgerEntry:
        if not is_kst_iso_day(self.kst_day):
            raise PydanticCustomError("topic59_kst_day", "invalid KST day")
        if not topic59_payload_is_safe(self.payload):
            raise PydanticCustomError("topic59_payload", "invalid Topic-59 payload")
        if self.payload["customer_identity_digest"] != self.customer_identity_digest:
            raise PydanticCustomError("topic59_customer", "mismatched customer")
        if self.payload["kst_day"] != self.kst_day:
            raise PydanticCustomError("topic59_day", "mismatched KST day")
        return self


class Topic59LedgerHistory:
    def __init__(self, path: Path) -> None:
        self._path: Path = path

    @contextmanager
    def locked(self) -> Generator[TextIO, None, None]:
        self._path.parent.mkdir(parents=True, exist_ok=True)
        with self._path.open("a+", encoding="utf-8") as stream:
            _ = fcntl.flock(stream.fileno(), fcntl.LOCK_EX)
            try:
                yield stream
            finally:
                _ = fcntl.flock(stream.fileno(), fcntl.LOCK_UN)

    def read(self, stream: TextIO) -> tuple[Topic59LedgerEntry, ...]:
        _ = stream.seek(0)
        rows: list[Topic59LedgerEntry] = []
        for line in stream:
            try:
                row = Topic59LedgerEntry.model_validate_json(line)
            except ValidationError as error:
                raise Topic59LedgerCorruption("ledger row") from error
            self._validate_chain(row, rows[-1] if rows else None)
            rows.append(row)
        return tuple(rows)

    def append(
        self,
        stream: TextIO,
        rows: tuple[Topic59LedgerEntry, ...],
        projection: Topic59Projection,
        operation: Literal["send", "edit"],
        state: Topic59LedgerState,
        message_id: str | None = None,
        provider_reason: Topic59ProviderFailure | None = None,
        incident_reason: Topic59IncidentReason | None = None,
    ) -> tuple[Topic59LedgerEntry, ...]:
        predecessor = rows[-1].entry_digest if rows else _ZERO_DIGEST
        unsigned = Topic59LedgerEntry(
            schema=_LEDGER_SCHEMA,
            sequence=len(rows) + 1,
            predecessor_digest=predecessor,
            entry_digest=_ZERO_DIGEST,
            card_slot=projection.card_slot,
            logical_key=projection.logical_key,
            customer_identity_digest=projection.customer_identity_digest,
            kst_day=projection.kst_day.isoformat(),
            config_digest=projection.config_digest,
            candidate_digest=projection.candidate_digest,
            route_digest=projection.route_digest,
            status_digest=projection.status_digest,
            payload_digest=projection.payload_digest,
            operation=operation,
            state=state,
            payload=projection.payload,
            message_id=message_id,
            provider_reason=provider_reason,
            incident_reason=incident_reason,
        )
        row = unsigned.model_copy(update={"entry_digest": self._digest(unsigned)})
        _ = stream.write(row.model_dump_json(by_alias=True) + "\n")
        stream.flush()
        _ = os.fsync(stream.fileno())
        _ = stream.seek(0, 2)
        return (*rows, row)

    @staticmethod
    def _validate_chain(row: Topic59LedgerEntry, prior: Topic59LedgerEntry | None) -> None:
        if row.sequence != (prior.sequence + 1 if prior else 1):
            raise Topic59LedgerCorruption("ledger sequence")
        if row.predecessor_digest != (prior.entry_digest if prior else _ZERO_DIGEST):
            raise Topic59LedgerCorruption("ledger predecessor")
        if row.entry_digest != Topic59LedgerHistory._digest(row):
            raise Topic59LedgerCorruption("ledger digest")

    @staticmethod
    def _digest(row: Topic59LedgerEntry) -> str:
        value = row.model_dump(mode="json", by_alias=True, exclude={"entry_digest"})
        encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
        return hashlib.sha256(encoded).hexdigest()
