"""
Receipt Writer (Atomic, Signed, Indexed)

---
feature:
  id: feature.receipt_writer
  name: Receipt Writer
  kind: module
  summary: Atomic, append-only receipt writer with signing and index updates
  owners: ["@codex"]
  stage: production
  tags: [receipts, p0]
  depends_on: []
  inputs: [BaseReceipt]
  outputs: [ReceiptEnvelope]
  invariants:
    - Writes are atomic (temp -> final -> fsync)
    - Hash and JCS are consistent with payload
  risk_axes:
    partial_write: 0.02
    index_drift: 0.01
---
"""

from __future__ import annotations

import hmac
import io
import json
import logging
import os
import re
import threading
import uuid
from dataclasses import dataclass, field
from enum import Enum
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Mapping, Optional, Literal, Set, Tuple

import pandas as pd

from api.errors import ApiError, ErrorCode
from config.paths import DATA_ROOT
from receipts.learning_contracts import LearningTier, compute_learning_tier, validate_action_class
from receipts.models import BaseReceipt, RefusalStone, RecoveryReceipt, compute_hash
from receipts.canonicalize import to_jcs_bytes, compute_payload_hash
from receipts.compat.pyd import unwrap_frozen
from receipts.domains.governance import TimestampPolicyReceipt
from receipts.privacy import seal_sensitive_payload
from receipts.chain_index import update_chain_index, lookup_chain_head
from receipts.partitioning import normalise_token, partition_slug
from receipts.storage import write_bytes_atomic, read_bytes, _fsync_dir
from receipts.sign import sign_receipt, SigningUnavailableError
from receipts.cbor import payload_to_cbor
from receipts.sign_cose import sign_receipt_cose
from tools.active_learning import enqueue as active_learning_enqueue
from tools.lib import metrics as observability_metrics
from tools import proofkit as proofkit_module
from engine.pipeline.telemetry import timer
from otel.init_otel import trace_span
from engine.events import publisher as webhook_publisher
from receipts.index import upsert_receipt_index, lookup_receipt
from receipts.timestamp_policy import evaluate_timestamp_policy
from jcs import canonicalize as jcs_canonicalize
from receipts.immutability import deep_freeze, ensure_frozen, freeze_model, immutable_receipts_enabled
from receipts.idempotent_writer import IdempotentWriter
from receipts.base_event import create_receipt_from_legacy
from receipts.event_store import EventStore
from jsonschema.exceptions import ValidationError
from engine.memory.memory_graph import publish_to_memory_graph
from receipts.validate import validate_receipt
from receipts.mem0_ingest import ingest_receipt as mem0_ingest_receipt
from receipts.policy_registry import get_domain_policy
from loom.proof_config import get_proof_config, ProofMode
from receipts.policy_writer import compute_attestation, AttestationResult
from receipts.trust import compute_trust_profile

__all__ = ["write_receipt", "write_receipt_envelope", "atomic_write", "MemoryGuardViolation"]

_RECEIPT_HASH_CACHE: Dict[str, str] = {}
_RECEIPT_HASH_CACHE_LOCK = threading.RLock()
_PARENT_PAYLOAD_CACHE: Dict[str, Dict[str, Any]] = {}
_PARENT_PAYLOAD_CACHE_LOCK = threading.RLock()
_RECOVERY_REGISTRY: Dict[Path, "RecoveryState"] = {}
_RECOVERY_LOCK = threading.Lock()
_IDEMPOTENT_WRITERS: Dict[str, IdempotentWriter] = {}
_IDEMPOTENT_WRITER_LOCK = threading.RLock()
_EVENT_STORES: Dict[str, EventStore] = {}
_EVENT_STORE_LOCK = threading.RLock()
logger = logging.getLogger("loom.receipts.writer")

_MAX_RECOVERY_ATTEMPTS = 3
_MAX_RECOVERY_OPERATIONS = 2048

_BINARY_TWIN_RECEIPT_TYPES: Set[str] = {
    "ProphecyStone",
    "RefusalStone",
    "GateWitness",
    "PhaseStone",
    "PermissionReceipt",
    # SIGIL constitutional vetting receipts
    "IntentReceipt",
    "PreflightApprovalReceipt",
    # Note: SIGIL RefusalStone is distinct from legacy RefusalStone above
}

_TYPED_METRICS_ENV_VAR = "TYPED_METRICS_WRITE"
_FLAG_TRUTHY = {"1", "true", "yes", "on"}
_TYPED_METRIC_FIELDS: Dict[str, Tuple[str, ...]] = {
    "ProphecyStone": ("calibration_typed", "coherence_typed", "dignity_typed"),
    "RefusalStone": ("calibration_typed", "coherence_typed", "dignity_typed"),
    # Extend for OutcomeReceipt/GateWitness once typed payloads are available.
}

_PROOFKIT_HMAC_KEY_ENV = "PROOFKIT_HMAC_KEY"
_PROOFKIT_HMAC_SENTINEL = object()
_proofkit_hmac_key_cache: object | bytes | None = None
_proofkit_env_logged = False


def _now_utc_millis() -> datetime:
    now = datetime.now(timezone.utc)
    if now.microsecond:
        now = now.replace(microsecond=(now.microsecond // 1000) * 1000)
    return now


def _typed_metrics_write_enabled() -> bool:
    token = os.getenv(_TYPED_METRICS_ENV_VAR, "").strip().lower()
    return token in _FLAG_TRUTHY


def _assert_typed_metric_immutable(receipt: BaseReceipt, field: str, typed_value: Any) -> None:
    if typed_value is None:
        return
    if immutable_receipts_enabled():
        try:
            freeze_model(typed_value)
        except Exception:
            logger.exception(
                "Failed to freeze typed metric block",
                extra={"receipt_type": receipt.type, "field": field},
            )
            raise
    ensure_frozen(typed_value, context=f"{receipt.type}.{field}")


def _enforce_typed_metrics(receipt: BaseReceipt) -> None:
    if not _typed_metrics_write_enabled():
        return
    required_fields = _TYPED_METRIC_FIELDS.get(receipt.type)
    if not required_fields:
        return
    missing = [field for field in required_fields if getattr(receipt, field, None) is None]
    if missing:
        missing_csv = ", ".join(sorted(missing))
        raise ApiError(
            code=ErrorCode.MISSING_FIELD,
            title="Typed metrics required",
            detail=(
                f"{receipt.type} receipts must include typed metrics ({missing_csv}) "
                f"when {_TYPED_METRICS_ENV_VAR}=1"
            ),
            status=422,
            extras={
                "receipt_type": receipt.type,
                "missing_typed_fields": missing,
                "feature_flag": _TYPED_METRICS_ENV_VAR,
            },
        )
    for field in required_fields:
        _assert_typed_metric_immutable(receipt, field, getattr(receipt, field))


@dataclass
class RecoveryState:
    attempts: int = 0
    operations: List[Dict[str, Any]] = field(default_factory=list)
    errors: List[Dict[str, Any]] = field(default_factory=list)
    pending_tmp: int = 0
    receipt_emitted: bool = False
    closed: bool = False


class MemoryGuardViolation(RuntimeError):
    """Raised when memory snippets attempt to enter guarded receipt fields."""

    def __init__(self, message: str, refusal: RefusalStone, *, guard_paths: List[str]) -> None:
        super().__init__(message)
        self.refusal_receipt = refusal
        self.guard_paths = guard_paths


def _emit_signing_refusal(
    receipt: BaseReceipt,
    exc: SigningUnavailableError,
    *,
    root: Optional[Path],
    payload: Dict[str, Any],
) -> None:
    """
    Emit a RefusalStone when canonical signing is unavailable.

    Best-effort: does not recurse into signing, skips recovery, and logs failures.
    """
    logger.error(
        "Canonical signing failed - emitting RefusalStone",
        extra={
            "receipt_id": payload.get("receipt_id", "unknown"),
            "tenant": receipt.tenant,
            "error": str(exc),
        },
    )

    refusal = RefusalStone(
        receipt_id=f"refusal_{uuid.uuid4().hex[:16]}",
        ts=datetime.now(timezone.utc),
        tenant=receipt.tenant,
        domain=receipt.domain,
        asset_id=payload.get("receipt_id", "unknown"),
        reasons=[f"SigningUnavailable: {exc.reason}"],
        policy_reasons=[exc.reason],
        run_context={
            "original_receipt_id": payload.get("receipt_id"),
            "signing_error": str(exc),
            "tenant": exc.tenant,
        },
    )

    try:
        _write_receipt_impl(refusal, root=root, _skip_recovery=True, _skip_signing=True)
    except Exception:  # pragma: no cover - refusal persistence is best-effort
        logger.warning(
            "Failed to persist RefusalStone for signing failure",
            extra={"original_receipt_id": payload.get("receipt_id", "unknown")},
        )


def _assign(model: BaseReceipt, field: str, value: Any) -> None:
    if immutable_receipts_enabled():
        value = deep_freeze(value)
    object.__setattr__(model, field, value)


def _ensure_recovery(*, root: Optional[Path] = None) -> None:
    base = Path(root or DATA_ROOT).resolve()
    with _RECOVERY_LOCK:
        state = _RECOVERY_REGISTRY.get(base)
        if state and state.closed and state.pending_tmp == 0:
            # CLEANUP: Remove completed recovery states to prevent memory leak
            _RECOVERY_REGISTRY.pop(base, None)
            state = RecoveryState()
        elif state and state.closed and state.pending_tmp > 0:
            return
        try:
            state = _recover_partial_writes(base, state)
        except Exception:
            logger.exception("Failed to perform receipt recovery", extra={"base": str(base)})
            if state is None:
                state = RecoveryState()
            state.closed = True
        _RECOVERY_REGISTRY[base] = state


def _get_idempotent_writer(base_path: Path) -> Optional[IdempotentWriter]:
    """
    Retrieve or initialize an IdempotentWriter scoped to the base path.
    """
    key = str(base_path.resolve())
    with _IDEMPOTENT_WRITER_LOCK:
        writer = _IDEMPOTENT_WRITERS.get(key)
        if writer is None:
            try:
                dedupe_dir = base_path / "receipts" / ".dedupe"
                writer = IdempotentWriter(base_dir=dedupe_dir)
            except Exception:  # pragma: no cover - dedupe should not block writes
                logger.exception("Failed to initialize IdempotentWriter", extra={"base_path": key})
                writer = None
            _IDEMPOTENT_WRITERS[key] = writer
        return writer


def _get_event_store(base_path: Path) -> Optional[EventStore]:
    """
    Retrieve or initialize an EventStore scoped to the base path.

    LEM Integration: EventStore provides two-tier storage (hot SQLite + cold Parquet)
    for unified event streams, evidence chains, and tri-temporal queries.
    """
    key = str(base_path.resolve())
    with _EVENT_STORE_LOCK:
        store = _EVENT_STORES.get(key)
        if store is None:
            try:
                events_dir = base_path / "events"
                store = EventStore(data_dir=events_dir, hot_retention_days=7, batch_size=100)
            except Exception:  # pragma: no cover - event store should not block writes
                logger.exception("Failed to initialize EventStore", extra={"base_path": key})
                store = None
            _EVENT_STORES[key] = store
        return store


def _recover_partial_writes(base_path: Path, state: Optional[RecoveryState] = None) -> RecoveryState:
    if state is None or (state.closed and state.pending_tmp == 0):
        state = RecoveryState()

    state.attempts += 1
    inbox_root = base_path / "receipts" / "inbox"
    ledger_root = base_path / "receipts" / "ledger"
    targets = [inbox_root, ledger_root]

    operations_batch: List[Dict[str, Any]] = []
    errors_batch: List[Dict[str, Any]] = []
    processed = 0
    for root in targets:
        if not root.exists():
            continue
        for tmp_path in root.rglob("*.tmp"):
            detail: Dict[str, Any] = {}
            try:
                detail["tmp"] = str(tmp_path.relative_to(base_path))
            except ValueError:
                detail["tmp"] = str(tmp_path)
            final_path = Path(str(tmp_path)[:-4])
            try:
                detail["final"] = str(final_path.relative_to(base_path))
            except ValueError:
                detail["final"] = str(final_path)

            if processed >= _MAX_RECOVERY_OPERATIONS:
                detail["action"] = "skipped"
                detail["attempt"] = state.attempts
                operations_batch.append(detail)
                continue

            processed += 1

            try:
                if final_path.exists():
                    tmp_path.unlink()
                    detail["action"] = "purged"
                else:
                    os.replace(tmp_path, final_path)
                    fd: Optional[int] = None
                    try:
                        fd = os.open(str(final_path), os.O_RDONLY)
                        with timer("fsync"):
                            os.fsync(fd)
                    except OSError:
                        pass
                    finally:
                        if fd is not None:
                            try:
                                os.close(fd)
                            except OSError:
                                pass
                    _fsync_dir(final_path)
                    detail["action"] = "completed"
            except Exception as exc:  # pragma: no cover - defensive logging
                detail["action"] = "error"
                detail["error"] = str(exc)
                detail["attempt"] = state.attempts
                errors_batch.append(detail)
            else:
                detail["attempt"] = state.attempts
                operations_batch.append(detail)

    leftover_tmp = 0
    for root in targets:
        if not root.exists():
            continue
        leftover_tmp += sum(1 for _ in root.rglob("*.tmp"))

    state.operations.extend(operations_batch)
    state.errors.extend(errors_batch)
    state.pending_tmp = leftover_tmp

    should_emit = bool(state.operations or state.errors)
    finalize = (state.pending_tmp == 0) or (state.attempts >= _MAX_RECOVERY_ATTEMPTS)

    if should_emit and finalize:
        if state.pending_tmp > 0 and state.attempts >= _MAX_RECOVERY_ATTEMPTS:
            logger.warning(
                "Recovery attempts exhausted with remaining temporary files",
                extra={
                    "base": str(base_path),
                    "remaining_tmp": state.pending_tmp,
                    "attempts": state.attempts,
                },
            )
        _emit_recovery_receipt(base_path, state)
        state.receipt_emitted = True
    else:
        state.receipt_emitted = False

    state.closed = state.pending_tmp == 0 or state.attempts >= _MAX_RECOVERY_ATTEMPTS
    return state


def _coalesce_entries(entries: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    if not entries:
        return []
    merged: Dict[Tuple[Optional[str], Optional[str]], Dict[str, Any]] = {}
    for entry in entries:
        tmp = entry.get("tmp")
        final_path = entry.get("final")
        key = (tmp, final_path)
        merged[key] = entry
    return list(merged.values())


def _emit_recovery_receipt(base_path: Path, state: RecoveryState) -> None:
    operations = _coalesce_entries(state.operations)
    errors = _coalesce_entries(state.errors)
    metadata = {
        "base_path": str(base_path),
        "attempts": state.attempts,
        "pending_tmp": state.pending_tmp,
        "max_attempts": _MAX_RECOVERY_ATTEMPTS,
    }
    receipt = RecoveryReceipt(
        receipt_id=f"recovery-{uuid.uuid4()}",
        domain="governance",
        mode="MAINTENANCE",
        operations=operations,
        errors=errors,
        context={"recovery": metadata},
    )
    _write_receipt_impl(receipt, root=base_path, _skip_recovery=True)
    state.operations.clear()
    state.errors.clear()


def atomic_write(path: Path, data: bytes, tenant: str) -> None:
    """
    Persist data atomically using a deterministic *.tmp suffix and durable fsync.

    Args:
        path: Target file path.
        data: Bytes payload (already canonicalised and signed).
        tenant: Tenant identifier for KMS envelope encryption.
    """
    tmp_path = Path(f"{path}.tmp")
    tmp_path.parent.mkdir(parents=True, exist_ok=True)

    write_bytes_atomic(tmp_path, data, tenant)
    os.replace(tmp_path, path)

    fd: Optional[int] = None
    try:
        fd = os.open(str(path), os.O_RDONLY)
        with timer("fsync"):
            os.fsync(fd)
    except OSError:
        pass
    finally:
        if fd is not None:
            try:
                os.close(fd)
            except OSError:
                pass
    _fsync_dir(path)


def _resolve_tenant(receipt: BaseReceipt) -> str:
    candidates = [
        getattr(receipt, "tenant", None),
        (receipt.context or {}).get("tenant") if getattr(receipt, "context", None) else None,
    ]
    run_context = getattr(receipt, "run_context", None)
    if isinstance(run_context, dict):
        candidates.append(run_context.get("tenant"))
    for candidate in candidates:
        if candidate is None:
            continue
        if isinstance(candidate, Enum):
            candidate = candidate.value
        token = normalise_token(candidate)
        if token:
            return token
    domain_value = receipt.domain.value if isinstance(receipt.domain, Enum) else receipt.domain
    return normalise_token(domain_value)


def _mirror_typed_metrics(payload: Dict[str, Any]) -> None:
    """
    Ensure legacy metrics dictionaries mirror newly introduced typed blocks.

    Downstream consumers continue to read `metrics[...]` until the next release.
    """

    if not _typed_metrics_write_enabled():
        return

    typed_fields = (
        ("calibration_typed", "calibration"),
        ("coherence_typed", "coherence"),
        ("dignity_typed", "dignity"),
    )

    has_typed = any(isinstance(payload.get(field), Mapping) for field, _ in typed_fields)
    if not has_typed:
        return

    metrics_obj = payload.get("metrics")
    if not isinstance(metrics_obj, dict):
        metrics_obj = {}
        payload["metrics"] = metrics_obj

    for field_name, alias in typed_fields:
        typed_block = payload.get(field_name)
        if not isinstance(typed_block, Mapping):
            continue
        alias_payload = metrics_obj.get(alias)
        alias_dict = dict(alias_payload) if isinstance(alias_payload, Mapping) else {}
        changed = False
        typed_dict = dict(typed_block)
        for key in sorted(typed_dict):
            value = typed_dict[key]
            if metrics_obj.get(key) != value:
                metrics_obj[key] = value
                changed = True
            if alias_dict.get(key) != value:
                alias_dict[key] = value
                changed = True
        if alias_dict or changed:
            metrics_obj[alias] = alias_dict


def _get_proofkit_hmac_key() -> Optional[bytes]:
    global _proofkit_hmac_key_cache, _proofkit_env_logged
    if _proofkit_hmac_key_cache is _PROOFKIT_HMAC_SENTINEL:
        return None
    if isinstance(_proofkit_hmac_key_cache, bytes):
        return _proofkit_hmac_key_cache

    token = os.getenv(_PROOFKIT_HMAC_KEY_ENV, "").strip()
    if not token:
        _proofkit_hmac_key_cache = _PROOFKIT_HMAC_SENTINEL
        if not _proofkit_env_logged:
            logger.debug(
                "ProofKit attestation key not configured; skipping ProofKit attestation."
            )
            _proofkit_env_logged = True
        return None

    _proofkit_hmac_key_cache = token.encode("utf-8")
    logger.info("ProofKit attestation enabled via %s", _PROOFKIT_HMAC_KEY_ENV)
    return _proofkit_hmac_key_cache


def _attach_proofkit_attestation(payload: Dict[str, Any]) -> None:
    if not isinstance(payload, dict):
        return

    context_obj = payload.get("context")
    if isinstance(context_obj, Mapping) and context_obj.get("proofkit_attestation"):
        return

    key = _get_proofkit_hmac_key()
    if not key:
        return

    try:
        attested = proofkit_module.sign_receipt(payload, key=key, algorithm="hmac-sha256")
    except Exception:
        logger.exception(
            "Failed to produce ProofKit attestation",
            extra={"receipt_id": payload.get("receipt_id")},
        )
        return

    attestation = attested.get("attestation")
    if not attestation:
        return

    updated_context = dict(context_obj) if isinstance(context_obj, Mapping) else {}
    updated_context["proofkit_attestation"] = attestation
    payload["context"] = updated_context


def _canonical_payload(receipt: BaseReceipt) -> Dict[str, Any]:
    _enforce_typed_metrics(receipt)
    payload = receipt.model_dump(mode="json", by_alias=True)
    payload.setdefault("receipt_type", getattr(receipt, "receipt_type", receipt.__class__.__name__))
    label_prefix = str(payload.get("receipt_type", receipt.__class__.__name__))
    _mirror_typed_metrics(payload)
    try:
        seal_sensitive_payload(receipt, payload, label_prefix=label_prefix)
    except Exception:  # pragma: no cover - defensive to avoid blocking writes
        logger.exception("Failed to apply privacy envelopes for %s", receipt.receipt_id)
    _attach_proofkit_attestation(payload)
    return payload


def _scan_memory_snippets(node: Any, path: Tuple[str, ...] = ()) -> List[Tuple[Tuple[str, ...], Any]]:
    findings: List[Tuple[Tuple[str, ...], Any]] = []
    # Regex pattern to catch variations: memory_snippets, memory-snippets, memorySnippets, etc.
    memory_pattern = re.compile(r'memory[\-_]?snippets', re.IGNORECASE)
    if isinstance(node, Mapping):
        for key, value in node.items():
            next_path = path + (str(key),)
            if memory_pattern.match(str(key)):
                findings.append((next_path, value))
            findings.extend(_scan_memory_snippets(value, next_path))
    elif isinstance(node, list):
        for index, item in enumerate(node):
            findings.extend(_scan_memory_snippets(item, path + (str(index),)))
    return findings


def _extract_selection_hash(payload: Mapping[str, Any]) -> Optional[str]:
    candidate = payload.get("memory_selection_hash")
    if isinstance(candidate, str):
        return candidate
    context = payload.get("context")
    if isinstance(context, Mapping):
        selection = context.get("memory_selection")
        if isinstance(selection, Mapping):
            hash_candidate = selection.get("selection_hash")
            if isinstance(hash_candidate, str):
                return hash_candidate
    return None


def _mint_memory_guard_refusal(
    source_receipt: BaseReceipt,
    payload: Mapping[str, Any],
    guard_paths: List[str],
) -> RefusalStone:
    selection_hash = _extract_selection_hash(payload)
    run_context = dict(getattr(source_receipt, "run_context", {}) or {})
    run_context.update(
        {
            "guard": "memory_evidence_write",
            "violations": guard_paths,
            "source_receipt_id": source_receipt.receipt_id,
        }
    )

    return RefusalStone(
        receipt_id=f"{source_receipt.receipt_id}-memory-guard-{uuid.uuid4()}",
        domain=source_receipt.domain,
        asset_id=getattr(source_receipt, "asset_id", "unknown"),
        reasons=["memory_guard_violation"],
        gate_failures={"memory_guard": {"paths": guard_paths}},
        metrics={"memory_status": "blocked"},
        run_context=run_context,
        related_ids={"source_receipt_id": source_receipt.receipt_id},
        memory_blocked_reason="memory_guard_violation",
        memory_selection_hash=selection_hash,
    )


def _guard_memory_evidence(
    source_receipt: BaseReceipt,
    payload: Mapping[str, Any],
    *,
    root: Optional[Path] = None,
) -> None:
    findings = _scan_memory_snippets(payload)
    if not findings:
        return
    guard_paths = [".".join(path) for path, _ in findings]
    refusal = _mint_memory_guard_refusal(source_receipt, payload, guard_paths)
    # Memory guard refusals are best-effort: persist without enforcing signing
    _write_receipt_impl(refusal, root=root, _skip_recovery=True, _skip_signing=True)
    raise MemoryGuardViolation("memory_guard_violation", refusal, guard_paths=guard_paths)


def _normalise_partition_date(ts_value: Any) -> str:
    if isinstance(ts_value, datetime):
        return ts_value.date().isoformat()
    if isinstance(ts_value, str):
        candidate = ts_value
        if candidate.endswith("Z"):
            candidate = candidate[:-1] + "+00:00"
        try:
            parsed = datetime.fromisoformat(candidate)
            return parsed.date().isoformat()
        except ValueError:
            return candidate[:10]
    return date.today().isoformat()


def _encode_nested(value: Any) -> Any:
    if isinstance(value, (dict, list)):
        return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
    return value


def _extract_signature_block(payload: Mapping[str, Any]) -> Optional[Any]:
    candidate = payload.get("signature")
    if candidate is not None:
        return candidate
    sigs = payload.get("signatures")
    if isinstance(sigs, list) and sigs:
        first = sigs[0]
        if first is not None:
            return first
    audit = payload.get("audit")
    if isinstance(audit, Mapping):
        signature = audit.get("signature")
        if signature is not None:
            return signature
    return None


def _extract_tsa_block(payload: Mapping[str, Any]) -> Optional[Any]:
    audit = payload.get("audit")
    if isinstance(audit, Mapping):
        timestamp = audit.get("timestamp") or audit.get("tsa")
        if timestamp is not None:
            return timestamp
    timestamp = payload.get("timestamp")
    if timestamp is not None:
        return timestamp
    return None


def write_receipt_envelope(
    payload: Mapping[str, Any],
    *,
    format: Literal["json", "cbor"] = "json",
) -> bytes:
    """
    Serialise a receipt payload into a transport envelope.

    The canonical JSON body (RFC 8785) remains the source-of-truth for hashing
    and signature verification. CBOR envelopes provide a machine-efficient twin
    without altering the signed bytes.
    """

    format_token = format.lower()
    if format_token not in {"json", "cbor"}:
        raise ValueError(f"Unsupported envelope format '{format}'. Expected 'json' or 'cbor'.")

    canonical_payload_bytes = to_jcs_bytes(payload)
    payload_node = json.loads(canonical_payload_bytes.decode("utf-8"))
    digest_hex = compute_payload_hash(payload_node)
    signature_block = _extract_signature_block(payload_node)
    tsa_block = _extract_tsa_block(payload_node)

    envelope: Dict[str, Any] = {
        "@content_type": "application/canonical+json" if format_token == "json" else "application/cbor",
        "@canonical_alg": "jcs-rfc8785" if format_token == "json" else "cbor-deterministic",
        "@hash": digest_hex,
        "@sig_alg": "EdDSA-Ed25519",
        "payload": payload_node,
    }
    if signature_block is not None:
        envelope["@signature"] = signature_block
    if tsa_block is not None:
        envelope["@tsa"] = tsa_block

    if format_token == "json":
        return to_jcs_bytes(envelope)

    from receipts.cbor_encoder import encode as cbor_encode

    # Ensure deterministic shaping by canonicalising the envelope prior to CBOR encoding.
    canonical_envelope = json.loads(to_jcs_bytes(envelope).decode("utf-8"))
    return cbor_encode(canonical_envelope)


def _write_receipt_impl(
    receipt: BaseReceipt,
    *,
    root: Optional[Path] = None,
    _skip_recovery: bool = False,
    _skip_signing: bool = False,
) -> None:
    """
    Persist a receipt to the ledger (Parquet) and inbox (canonical JSON).
    Uses thread-based chain index for O(1) parent lookups when thread_id is set.

    Args:
        receipt: Receipt model instance to serialise.
        root: Optional override for DATA_ROOT (useful in tests).
    """
    if not _skip_recovery:
        _ensure_recovery(root=root)

    base_path = Path(root or DATA_ROOT)
    assigned_system_time = _now_utc_millis()
    _assign(receipt, "system_time", assigned_system_time)
    tenant_value = _resolve_tenant(receipt)
    _assign(receipt, "tenant", tenant_value)
    primary_parent_hash = _ensure_parent_hashes(receipt, base_path, root=root)
    payload = _canonical_payload(receipt)
    payload["tenant"] = tenant_value
    if primary_parent_hash:
        payload.setdefault("parent_hash", primary_parent_hash)

    # ---------------------------------------------------------------------- #
    # Constitutional: action_class enforcement + learning tier computation
    # ---------------------------------------------------------------------- #
    action_class = payload.get("action_class")
    has_action_payload = any(key in payload for key in ("proposed_action", "executed_action"))

    if has_action_payload and not action_class:
        snippet = {k: payload.get(k) for k in ("type", "receipt_id", "domain", "tenant")}
        raise ApiError(
            code=ErrorCode.MISSING_FIELD,
            title="action_class required for action receipts",
            detail="Receipts with proposed/executed actions must include action_class",
            status=422,
            extras={"payload": snippet},
        )

    if action_class:
        try:
            validate_action_class(str(action_class))
        except ValueError as exc:
            raise ApiError(
                code=ErrorCode.INVALID_FIELD,
                title="Invalid action_class",
                detail=str(exc),
                status=422,
                extras={"payload": {"action_class": action_class}},
            ) from exc

        # Align model + payload to include validated action_class
        _assign(receipt, "action_class", str(action_class))
        payload["action_class"] = str(action_class)

        # Compute TrustProfile and learning_tier/eligible snapshot for this receipt
        # Environment fallback: LOOM_ENV or dev
        env_token = str(payload.get("environment") or os.getenv("LOOM_ENV", "dev"))
        # Validate env token against matrix definitions to avoid silent downgrades
        matrix = default_matrix()
        if env_token not in matrix._matrix:  # type: ignore[attr-defined]  # internal access for validation
            raise ApiError(
                code=ErrorCode.INVALID_FIELD,
                title="Invalid environment for proof policy",
                detail=f"Unknown environment '{env_token}' for proof policy matrix",
                status=422,
                extras={"payload": {"environment": env_token}},
            )
        trust_profile = compute_trust_profile(payload)
        learning_result = compute_learning_tier(
            trust_profile=trust_profile,
            action_class=str(action_class),
            environment=env_token,
            has_override=bool(payload.get("override_applied")),
            has_shadow_disagreement=bool(payload.get("shadow_disagreement")),
            intent_aligned=bool(payload.get("intent_aligned", True)),
        )
        _assign(receipt, "trust_profile", trust_profile.to_dict())
        _assign(receipt, "learning_tier", learning_result.learning_tier.value)
        _assign(receipt, "learning_eligible", learning_result.learning_eligible)

        payload["trust_profile"] = trust_profile.to_dict()
        payload["learning_tier"] = learning_result.learning_tier.value
        payload["learning_eligible"] = learning_result.learning_eligible

    _guard_memory_evidence(receipt, payload, root=root)

    # Hard guard: receipts must include receipt_id and domain before signing.
    missing_fields: List[str] = []
    if not payload.get("receipt_id"):
        missing_fields.append("receipt_id")
    if not payload.get("domain"):
        missing_fields.append("domain")
    if missing_fields:
        snippet = {k: payload.get(k) for k in ("type", "receipt_id", "domain", "tenant", "ts")}
        raise ApiError(
            code=ErrorCode.MISSING_FIELD,
            title="Receipt missing required identifiers",
            detail=f"Missing fields: {', '.join(missing_fields)}",
            status=422,
            extras={"payload": snippet},
        )

    try:
        validate_receipt(payload)
    except ValidationError:
        logger.exception(
            "Receipt validation failed",
            extra={"receipt_id": receipt.receipt_id, "receipt_type": receipt.type},
        )
        raise

    # Tri-temporal validation: enforce valid_time <= observed_at <= recorded_at
    try:
        from receipts.validate import validate_tri_temporal, validate_lineage
        
        tri_temp_result = validate_tri_temporal(payload, strict=False)
        if not tri_temp_result.valid:
            for violation in tri_temp_result.violations:
                logger.error(
                    "Tri-temporal violation detected",
                    extra={
                        "receipt_id": receipt.receipt_id,
                        "violation_type": violation.violation_type,
                        "message": violation.message,
                    },
                )
            # In strict mode (court-grade domains), this would raise
            # For now, we log and continue but mark the receipt
            payload.setdefault("_validation", {})
            payload["_validation"]["tri_temporal_violations"] = [
                {"type": v.violation_type, "message": v.message}
                for v in tri_temp_result.violations
            ]
        
        lineage_result = validate_lineage(payload, strict=False)
        if lineage_result.warnings:
            for warning in lineage_result.warnings:
                logger.debug("Lineage warning: %s", warning)
    except Exception:  # pragma: no cover - validation should not block writes
        logger.debug(
            "Tri-temporal validation skipped",
            extra={"receipt_id": receipt.receipt_id},
        )

    # NOTE: Policy enforcement requires knowledge of finalized signature/TSA/Merkle
    # state. The canonical signing occurs below; to make the policy checks robust
    # we perform enforcement after the signature block is attached. A follow-up
    # background anchoring process will upgrade pending receipts when available.

    dedupe_writer = _get_idempotent_writer(base_path)
    dedupe_result: Optional[Dict[str, Any]] = None
    if dedupe_writer is not None:
        try:
            dedupe_result = dedupe_writer.check_duplicate(payload)
        except Exception:  # pragma: no cover - dedupe should not block writes
            logger.exception("Idempotent writer check failed", extra={"receipt_id": receipt.receipt_id})
            dedupe_result = None

    if immutable_receipts_enabled():
        ensure_frozen(receipt, context="receipt")

    receipt_hash = compute_hash(receipt)
    _assign(receipt, "receipt_hash", receipt_hash)
    payload["receipt_hash"] = receipt_hash
    try:
        logger.debug(
            "Assigned receipt_hash to payload",
            extra={"receipt_id": receipt.receipt_id, "receipt_hash": receipt_hash, "in_payload": ("receipt_hash" in payload)},
        )
    except Exception:
        pass

    # Extract receipt_id early for logging
    receipt_id = payload.get("receipt_id", "unknown")

    # Enforce canonical signing (no hash-only fallback) unless explicitly skipped
    signature_block = None

    if not _skip_signing:
        try:
            signature_envelope = sign_receipt(receipt, enforce=True)
            new_signatures = list(getattr(receipt, "signatures", []))
            new_signatures.append(signature_envelope)
            if immutable_receipts_enabled():
                frozen_signatures = deep_freeze(new_signatures)
                _assign(receipt, "signatures", frozen_signatures)
            else:
                _assign(receipt, "signatures", new_signatures)
            payload["signatures"] = [
                sig.model_dump(mode="json") if hasattr(sig, "model_dump") else sig  # pragma: no cover - defensive
                for sig in new_signatures
            ]
            signature_block = _extract_signature_block(payload)
            if signature_block is not None:
                if hasattr(signature_block, "model_dump"):
                    signature_block = signature_block.model_dump(mode="json")
                payload["signature"] = signature_block

            # Generate trustmark badge certifying all hardening checks passed
            # Skip if LOOM_TRUSTMARK_ENABLED=false (for burn-in and minimal testing)
            import os
            if os.environ.get("LOOM_TRUSTMARK_ENABLED", "true").lower() not in ("false", "0", "no"):
                try:
                    from receipts.trustmark import generate_trustmark
                    trustmark_badge = generate_trustmark(payload, version="v0.0.3-hardened")
                    payload["trustmark"] = trustmark_badge.to_dict()
                    payload["trustmark_status"] = "generated"
                    logger.debug(
                        "Generated trustmark badge for receipt",
                        extra={
                            "receipt_id": receipt_id,
                            "checks_passed": len(trustmark_badge.checks_passed)
                        }
                    )
                except Exception as exc:  # pragma: no cover - trustmark should not block writes
                    payload["trustmark_status"] = f"failed: {str(exc)}"
                    logger.warning(
                        "Failed to generate trustmark badge (non-blocking)",
                        extra={
                            "receipt_id": receipt_id,
                            "error": str(exc)
                        }
                    )
            else:
                payload["trustmark_status"] = "disabled"
        except SigningUnavailableError as exc:
            _emit_signing_refusal(receipt, exc, root=root, payload=payload)
            # Propagate error to caller
            raise

    # -----------------------
    # Policy enforcement now that signatures are present
    # -----------------------
    try:
        policy = get_domain_policy(receipt.domain or payload.get("domain") or "default")
        proof_config = get_proof_config()

        # Recompute flags after signing (signature_block now present)
        signed_flag = bool(signature_block)
        # Only consider TSA timestamp as 'timestamped' if TSA support is configured
        tsa_flag = bool(( _extract_tsa_block(payload) or payload.get("loomtsatoken") or payload.get("tsa_token") ) and proof_config.tsa.available)
        anchored_flag = bool(payload.get("merkle_root") or payload.get("loommerkleroot") or payload.get("root"))
        witnessed_flag = bool(payload.get("witness_signatures"))
        rekor_flag = bool(payload.get("rekor_entry_id") or payload.get("loomrekorindex") or payload.get("loomrekoruuid"))

        current_level = compute_attestation(
            signed=signed_flag,
            timestamped=tsa_flag,
            anchored=anchored_flag,
            witnessed=witnessed_flag,
            rekor_logged=rekor_flag,
        )

        # Persist attestation_level on the receipt model and payload
        # This makes the trust level self-identifying and queryable
        _assign(receipt, "attestation_level", current_level)
        payload["attestation_level"] = current_level.value

        achievable = proof_config.max_achievable_attestation()

        if current_level < policy.min_attestation:
            # Not meeting minimum now
            if achievable >= policy.min_attestation:
                # Will be remediated by background processes (e.g., anchoring) — mark pending
                payload.setdefault("_attestation", {})
                payload["_attestation"]["attestation_level"] = current_level.value
                payload["_attestation"]["pending_required_level"] = policy.min_attestation.value
                payload["_attestation"]["pending_reason"] = (
                    "final attestation will be upgraded by background anchoring/witness/rekor jobs"
                )
            else:
                # Cannot achieve required attestation with current config
                if proof_config.mode == ProofMode.STRICT:
                    raise ApiError(
                        code=ErrorCode.INVALID_OPERATION,
                        title="Attestation not achievable",
                        detail=(
                            f"Domain '{policy.name}' requires {policy.min_attestation.value} but system cannot achieve it "
                            f"(max achievable: {achievable.value}). Check proof configuration or use a different domain."
                        ),
                        status=412,
                        extras={"domain": policy.name, "required": policy.min_attestation.value, "achievable": achievable.value},
                    )
                else:
                    if proof_config.watermark_dev_receipts:
                        payload.setdefault("_attestation", {})
                        payload["_attestation"]["attestation_level"] = current_level.value
                        payload["_attestation"]["is_dev_watermarked"] = True
                        payload["_attestation"]["warning"] = (
                            f"Receipt does not meet domain minimum {policy.min_attestation.value}; running in {proof_config.mode.value} mode"
                        )
                    else:
                        logger.warning(
                            "Receipt attestation below domain requirement (allowed in non-STRICT mode)",
                            extra={"domain": policy.name, "required": policy.min_attestation.value, "achievable": achievable.value},
                        )
        else:
            # Meets or exceeds required level — attach attestation block
            att = AttestationResult(
                level=current_level,
                signed=signed_flag,
                timestamped=tsa_flag,
                anchored=anchored_flag,
                witnessed=witnessed_flag,
                rekor_logged=rekor_flag,
            )
            payload["_attestation"] = att.to_proof_block()
    except ApiError:
        # Propagate policy errors (e.g. STRICT failures)
        raise

    if immutable_receipts_enabled():
        ensure_frozen(receipt, context="receipt")

    partition_date = _normalise_partition_date(payload.get("ts"))
    tenant_slug = partition_slug(tenant_value)
    domain_slug = partition_slug(receipt.domain)
    inbox_dir = base_path / "receipts" / "inbox" / tenant_slug / domain_slug / partition_date
    inbox_dir.mkdir(parents=True, exist_ok=True)
    json_path = inbox_dir / f"{receipt_hash}.json"
    alias_path = inbox_dir / f"{receipt_id}.json"
    # Write the full payload (including signatures and receipt_hash) to disk.
    # `to_jcs_bytes` strips signature-related fields for hashing; use an
    # explicit canonical JSON that preserves signature and receipt_hash for
    # on-disk archival.
    try:
        full_payload = unwrap_frozen(payload)
        canonical_str = json.dumps(full_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
        canonical_bytes = canonical_str.encode("utf-8")
    except Exception:
        # Fallback to the defensive canonicaliser which strips signatures
        canonical_bytes = to_jcs_bytes(payload)
    # Debug: ensure receipt_hash present at write time (helps triage missing hash issues)
    try:
        logger.debug(
            "Writing canonical receipt JSON",
            extra={
                "json_path": str(json_path),
                "receipt_id": receipt_id,
                "receipt_hash_present": ("receipt_hash" in payload),
                "receipt_hash": payload.get("receipt_hash"),
            },
        )
    except Exception:
        # Logging must not break writes
        pass

    alias_exists = alias_path.exists()
    if (
        dedupe_result
        and dedupe_result.get("is_duplicate")
        and dedupe_result.get("receipt_id") == receipt_id
    ):
        existing_entry = lookup_receipt(receipt_id, root=root)
        if existing_entry and existing_entry.path.exists():
            if not alias_exists:
                try:
                    existing_bytes = existing_entry.path.read_bytes()
                    atomic_write(alias_path, existing_bytes, tenant_value)
                except Exception:  # pragma: no cover - alias recovery best effort
                    logger.exception(
                        "Failed to refresh alias link for duplicate receipt",
                        extra={"receipt_id": receipt_id},
                    )
            try:
                existing_payload = json.loads(existing_entry.path.read_text(encoding="utf-8"))
            except json.JSONDecodeError:
                existing_payload = None
            if existing_payload:
                existing_hash_value = existing_payload.get("receipt_hash")
                if isinstance(existing_hash_value, str):
                    _assign(receipt, "receipt_hash", existing_hash_value)
                    with _RECEIPT_HASH_CACHE_LOCK:
                        _RECEIPT_HASH_CACHE[receipt_id] = existing_hash_value
                existing_system_time = existing_payload.get("system_time")
                if existing_system_time:
                    coerced_system_time = _coerce_datetime(existing_system_time)
                    if coerced_system_time:
                        _assign(receipt, "system_time", coerced_system_time)
            logger.info(
                "Idempotent writer skipped duplicate receipt write",
                extra={"receipt_id": receipt_id},
            )
            return

    if json_path.exists():
        try:
            existing_bytes = read_bytes(json_path, tenant_hint=tenant_value)
        except Exception:
            existing_bytes = None
        if existing_bytes is not None and hmac.compare_digest(existing_bytes, canonical_bytes):
            if not alias_exists:
                atomic_write(alias_path, canonical_bytes, tenant_value)
            with _RECEIPT_HASH_CACHE_LOCK:
                _RECEIPT_HASH_CACHE[receipt_id] = receipt_hash
            logger.info(
                "Receipt already persisted; skipping duplicate write",
                extra={"receipt_id": receipt_id, "receipt_hash": receipt_hash},
            )
            return
        raise RuntimeError(
            f"Receipt hash collision for {receipt_id}: existing bytes differ for path {json_path}"
        )

    ledger_dir = base_path / "receipts" / "ledger" / tenant_slug / domain_slug / f"DATE={partition_date}"
    ledger_dir.mkdir(parents=True, exist_ok=True)

    serialised_row = {key: _encode_nested(value) for key, value in payload.items()}
    df = pd.DataFrame([serialised_row])

    existing_parts = list(ledger_dir.glob("part-*.parquet"))
    next_part = 0
    if existing_parts:
        part_nums = [int(path.stem.split("-")[1]) for path in existing_parts if "-" in path.stem]
        if part_nums:
            next_part = max(part_nums) + 1
    part_path = ledger_dir / f"part-{next_part:04d}.parquet"
    parquet_buffer = io.BytesIO()
    df.to_parquet(parquet_buffer, index=False)
    atomic_write(part_path, parquet_buffer.getvalue(), tenant_value)

    atomic_write(json_path, canonical_bytes, tenant_value)
    atomic_write(alias_path, canonical_bytes, tenant_value)

    if receipt.type in _BINARY_TWIN_RECEIPT_TYPES:
        try:
            cbor_bytes = payload_to_cbor(payload)
            cbor_path = inbox_dir / f"{receipt_hash}.cbor"
            cbor_alias_path = inbox_dir / f"{receipt_id}.cbor"
            atomic_write(cbor_path, cbor_bytes, tenant_value)
            atomic_write(cbor_alias_path, cbor_bytes, tenant_value)

            cose_bytes = sign_receipt_cose(receipt, payload=payload)
            if cose_bytes:
                cose_path = inbox_dir / f"{receipt_hash}.cose"
                cose_alias_path = inbox_dir / f"{receipt_id}.cose"
                atomic_write(cose_path, cose_bytes, tenant_value)
                atomic_write(cose_alias_path, cose_bytes, tenant_value)
        except Exception:
            logger.exception("Failed to emit CBOR/COSE twin for %s", receipt.receipt_id)

    with _RECEIPT_HASH_CACHE_LOCK:
        _RECEIPT_HASH_CACHE[receipt_id] = receipt_hash
    observability_metrics.record_receipt_written(
        receipt_type=str(payload.get("receipt_type", receipt.type))
    )

    # LEM Integration: Write to EventStore for unified event stream
    event_store = _get_event_store(base_path)
    if event_store is not None:
        try:
            # Convert legacy receipt to BaseEvent
            event = create_receipt_from_legacy(payload)
            event_store.write(event)
        except Exception:  # pragma: no cover - event store should not block writes
            logger.exception(
                "Failed to write receipt to EventStore",
                extra={"receipt_id": receipt_id, "receipt_type": receipt.type}
            )

    # Update thread chain index if thread_id is set
    if receipt.thread_id:
        update_chain_index(
            tenant=tenant_value,
            thread_id=receipt.thread_id,
            kind=receipt.type,
            receipt_id=receipt_id,
            receipt_hash=receipt_hash,
            timestamp=receipt.ts,
            root=root,
        )

    tombstone_for = getattr(receipt, "tombstoned_receipt_id", None)
    tombstone_reason = None
    if tombstone_for:
        reason_value = getattr(receipt, "reason", None)
        if hasattr(reason_value, "value"):
            reason_value = getattr(reason_value, "value")
        tombstone_reason = str(reason_value) if reason_value is not None else None

    decision = getattr(receipt, "decision", None)
    capability_obj = getattr(receipt, "capability", {}) if hasattr(receipt, "capability") else {}
    if isinstance(capability_obj, dict):
        action = capability_obj.get("action")
        resource = capability_obj.get("resource")
    else:
        action = getattr(capability_obj, "action", None)
        resource = getattr(capability_obj, "resource", None)
    organ_field = getattr(receipt, "organ", None)
    upsert_receipt_index(
        receipt_id=receipt_id,
        ts=payload.get("ts", receipt.ts),
        receipt_type=str(payload.get("receipt_type", receipt.type)),
        jcs_sha256=str(payload.get("receipt_hash", receipt_hash)),
        path=json_path,
        root=root,
        tombstone_for=tombstone_for,
        tombstone_reason=tombstone_reason,
        decision=decision,
        action=action,
        resource=resource,
        organ=organ_field,
    )

    publish_to_memory_graph(
        receipt=receipt,
        payload=payload,
        system_time=assigned_system_time,
    )

    # Mem0 Integration: Ingest receipt into semantic memory layer
    try:
        mem0_ingest_receipt(receipt, space=receipt.domain)
    except Exception:  # pragma: no cover - mem0 should not block writes
        logger.debug(
            "Failed to ingest receipt to Mem0 (non-blocking)",
            extra={"receipt_id": receipt_id, "receipt_type": receipt.type}
        )

    # Constitutional Witness: Record receipt in witness layer for self-audit
    try:
        from receipts.constitutional_witness import witness_receipt
        witness_receipt(payload)
    except Exception:  # pragma: no cover - witnessing should not block writes
        logger.debug(
            "Failed to witness receipt (non-blocking)",
            extra={"receipt_id": receipt_id, "receipt_type": receipt.type}
        )

    if dedupe_writer is not None:
        try:
            content_hash_token = (dedupe_result or {}).get("content_hash")
            dedupe_writer.register_unique(payload, content_hash=content_hash_token)
        except Exception:  # pragma: no cover - dedupe registration best effort
            logger.exception(
                "Failed to register receipt in IdempotentWriter index",
                extra={"receipt_id": receipt_id},
            )

    _publish_webhook_events(tenant_value, payload)

    if isinstance(receipt, RefusalStone):
        try:
            active_learning_enqueue.maybe_enqueue_label_request(receipt)
        except Exception:
            logger.exception(
                "Failed to enqueue label request for %s",
                receipt.receipt_id,
            )

    if not isinstance(receipt, TimestampPolicyReceipt):
        policy_receipt = evaluate_timestamp_policy(receipt, system_time=assigned_system_time)
        if policy_receipt is not None:
            try:
                _assign(policy_receipt, "parent_ids", [receipt.receipt_id])
                _assign(policy_receipt, "parent_hashes", [receipt_hash])
                _assign(policy_receipt, "parent_hash", receipt_hash)
                write_receipt(policy_receipt, root=root, _skip_recovery=_skip_recovery)
            except Exception:  # pragma: no cover - policy receipts should not block writes
                logger.exception(
                    "Failed to emit timestamp policy receipt",
                    extra={"source_receipt_id": receipt.receipt_id},
                )
        # Policy enforcement handled earlier (post-sign) so that written payloads
        # include `_attestation`. The old enforcement block here has been moved.


def write_receipt(
    receipt: BaseReceipt,
    *,
    root: Optional[Path] = None,
    _skip_recovery: bool = False,
    allow_unsigned: bool = False,
) -> None:
    """
    Write a receipt to the canonical ledger.

    Args:
        receipt: The receipt model to persist.
        root: Optional override for DATA_ROOT (useful in tests).
        _skip_recovery: Skip recovery checks (internal use).
        allow_unsigned: Allow unsigned receipts in DEV_STUB mode.
            If True and proof mode is DEV_STUB, signing failures will be
            caught and the receipt written with attestation_level=L0_DEV.
            In STRICT or RELAXED mode, this parameter is ignored and
            signing failures propagate as errors.
    """
    attributes = {
        "receipt.type": str(getattr(receipt, "type", receipt.__class__.__name__)),
        "receipt.domain": str(getattr(receipt, "domain", "unknown")),
    }
    if receipt.receipt_id:
        attributes["receipt.id"] = str(receipt.receipt_id)

    # Determine if we should skip signing based on proof mode
    skip_signing = False
    if allow_unsigned:
        proof_config = get_proof_config()
        if proof_config.mode == ProofMode.DEV_STUB:
            # In DEV_STUB, try signing first but allow fallback to unsigned
            # The _write_receipt_impl handles this via SigningUnavailableError
            # For simplicity, we'll skip signing upfront if signing is not available
            if not proof_config.signing.available:
                skip_signing = True

    with trace_span("receipts.write", **attributes):
        _write_receipt_impl(receipt, root=root, _skip_recovery=_skip_recovery, _skip_signing=skip_signing)


def _ensure_parent_hashes(receipt: BaseReceipt, base_path: Path, *, root: Optional[Path] = None) -> Optional[str]:
    parent_ids = _aggregate_parent_ids(receipt)

    # If no explicit parent_ids, try to resolve from thread chain
    if not parent_ids and receipt.thread_id:
        tenant = receipt.tenant or receipt.domain
        chain_head = lookup_chain_head(str(tenant), receipt.thread_id, root=root)
        if chain_head:
            parent_ids = [chain_head["last_receipt_id"]]
            _assign(receipt, "parent_ids", parent_ids)

    if not parent_ids:
        return None

    parent_hashes: List[str] = []
    dip_checked: Set[str] = set()

    for parent_id in parent_ids:
        parent_payload = _resolve_parent_payload(parent_id, base_path, root=root)
        parent_hash: Optional[str] = None
        if parent_payload is not None:
            hash_candidate = parent_payload.get("receipt_hash")
            if isinstance(hash_candidate, str):
                parent_hash = hash_candidate
                with _RECEIPT_HASH_CACHE_LOCK:
                    _RECEIPT_HASH_CACHE[parent_id] = hash_candidate
        if parent_hash is None:
            parent_hash = _lookup_parent_hash(parent_id, base_path, root=root)

        if parent_hash is None:
            if receipt.allow_orphan:
                continue
            raise RuntimeError(f"parent receipt '{parent_id}' not found for {receipt.receipt_id}")

        parent_hashes.append(parent_hash)

        if (
            _needs_dip_cutoff_validation(receipt)
            and parent_payload
            and (parent_payload.get("receipt_type") or parent_payload.get("type")) == "DIPReceipt"
        ):
            _validate_dip_cutoff(receipt, parent_payload)
            dip_checked.add(parent_id)

    if _needs_dip_cutoff_validation(receipt) and not dip_checked:
        related_dip = _resolve_related_dip_id(receipt)
        if related_dip:
            dip_payload = _resolve_parent_payload(related_dip, base_path, root=root)
            if dip_payload:
                _validate_dip_cutoff(receipt, dip_payload)

    _assign(receipt, "parent_ids", parent_ids)
    _assign(receipt, "parent_hashes", parent_hashes)
    if parent_hashes:
        _assign(receipt, "parent_hash", parent_hashes[0])
        return parent_hashes[0]
    return None


def _aggregate_parent_ids(receipt: BaseReceipt) -> List[str]:
    parent_ids: List[str] = []
    seen = set()
    single = getattr(receipt, "parent_id", None)
    if single:
        parent_ids.append(single)
        seen.add(single)
    iterable = getattr(receipt, "parent_ids", None) or []
    for pid in iterable:
        if not pid or pid in seen:
            continue
        parent_ids.append(pid)
        seen.add(pid)
    return parent_ids


def _resolve_parent_payload(
    receipt_id: str,
    base_path: Path,
    *,
    root: Optional[Path] = None,
) -> Optional[Dict[str, Any]]:
    with _PARENT_PAYLOAD_CACHE_LOCK:
        cached = _PARENT_PAYLOAD_CACHE.get(receipt_id)
        if cached is not None:
            return cached

    entry = lookup_receipt(receipt_id, root=root)
    if entry is not None:
        target = entry.path
    else:
        inbox_root = base_path / "receipts" / "inbox"
        if not inbox_root.exists():
            return None
        matches = list(inbox_root.rglob(f"{receipt_id}.json"))
        if not matches:
            return None
        target = max(matches, key=lambda path: path.stat().st_mtime)
    try:
        payload = json.loads(target.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        return None
    with _PARENT_PAYLOAD_CACHE_LOCK:
        _PARENT_PAYLOAD_CACHE[receipt_id] = payload
    receipt_hash = payload.get("receipt_hash")
    if isinstance(receipt_hash, str):
        with _RECEIPT_HASH_CACHE_LOCK:
            _RECEIPT_HASH_CACHE[receipt_id] = receipt_hash
    return payload


def _coerce_datetime(value: Any) -> Optional[datetime]:
    if isinstance(value, datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=timezone.utc)
        return value.astimezone(timezone.utc)
    if isinstance(value, str):
        token = value.strip()
        if not token:
            return None
        if token.endswith("Z"):
            token = token[:-1] + "+00:00"
        try:
            parsed = datetime.fromisoformat(token)
        except ValueError:
            return None
        if parsed.tzinfo is None:
            return parsed.replace(tzinfo=timezone.utc)
        return parsed.astimezone(timezone.utc)
    return None


def _needs_dip_cutoff_validation(receipt: BaseReceipt) -> bool:
    receipt_type = getattr(receipt, "receipt_type", receipt.__class__.__name__)
    return receipt_type in {"ProphecyStone", "RefusalStone"}


def _resolve_related_dip_id(receipt: BaseReceipt) -> Optional[str]:
    related = getattr(receipt, "related_ids", {}) or {}
    candidates: List[str] = []
    for key in ("dip_receipt_id", "dip_id", "dip", "root_dip_id"):
        value = related.get(key)
        if isinstance(value, str) and value:
            candidates.append(value)
        elif isinstance(value, (list, tuple, set)):
            candidates.extend(str(item) for item in value if item)
    for candidate in candidates:
        token = str(candidate).strip()
        if token:
            return token
    return None


def _validate_dip_cutoff(receipt: BaseReceipt, dip_payload: Mapping[str, Any]) -> None:
    cutoff = _coerce_datetime(dip_payload.get("cutoff_ts"))
    if cutoff is None:
        return
    child_ts = getattr(receipt, "ts", None)
    if not isinstance(child_ts, datetime):
        return
    child_ts_aware = child_ts if child_ts.tzinfo else child_ts.replace(tzinfo=timezone.utc)
    if child_ts_aware < cutoff:
        receipt_id = getattr(receipt, "receipt_id", "unknown")
        raise ValueError(
            f"{receipt.type} '{receipt_id}' ts {child_ts_aware.isoformat()} precedes DIP cutoff {cutoff.isoformat()}"
        )


def _lookup_parent_hash(receipt_id: str, base_path: Path, *, root: Optional[Path] = None) -> Optional[str]:
    with _RECEIPT_HASH_CACHE_LOCK:
        cached = _RECEIPT_HASH_CACHE.get(receipt_id)
        if cached:
            return cached
    payload = _resolve_parent_payload(receipt_id, base_path, root=root)
    if not payload:
        return None
    receipt_hash = payload.get("receipt_hash")
    if isinstance(receipt_hash, str):
        with _RECEIPT_HASH_CACHE_LOCK:
            _RECEIPT_HASH_CACHE[receipt_id] = receipt_hash
        return receipt_hash
    return None


def _publish_webhook_events(tenant: str, payload: Dict[str, Any]) -> None:
    """Fan out webhook events for newly written receipts."""

    if not tenant:
        return
    try:
        webhook_publisher.publish_receipt_ready(tenant=tenant, receipt_payload=payload)
        receipt_type = payload.get("receipt_type")
        if receipt_type == "PolicyDecisionReceipt":
            webhook_publisher.publish_decision_created(tenant=tenant, decision=payload)
        elif receipt_type in {"AuthorizationRefusalReceipt", "RefusalStone"}:
            webhook_publisher.publish_refusal_created(tenant=tenant, refusal=payload)
    except Exception:
        logger.exception(
            "Failed to publish webhook event",
            extra={"tenant": tenant, "receipt_type": payload.get("receipt_type")},
        )
