"""Opaque identifiers and exhaustive value validation for Topic-59 projections."""

from __future__ import annotations

import calendar
import hashlib
import re
from collections.abc import Mapping
from typing import Final, NewType


TOPIC59_PROJECTION_SCHEMA: Final = "nutricoach-topic59-day-card-v2"
TOPIC59_DIGEST_PATTERN: Final = r"^[0-9a-f]{64}$"
MAX_COMPLETED_FIELD_COUNT: Final = 64
OpaqueProjectionCustomerId = NewType("OpaqueProjectionCustomerId", str)
OpaqueProjectionEventId = NewType("OpaqueProjectionEventId", str)
OpaqueProjectionStatusId = NewType("OpaqueProjectionStatusId", str)
OpaqueProjectionDigest = NewType("OpaqueProjectionDigest", str)
OpaqueProjectionSlot = NewType("OpaqueProjectionSlot", str)
_DIGEST = re.compile(TOPIC59_DIGEST_PATTERN)
_PAYLOAD_KEYS: Final = frozenset(
    {
        "customer_identity_digest",
        "kst_day",
        "state",
        "completed_field_count",
        "draft_state",
        "approval_state",
        "delivery_state",
        "event_id",
        "status_row_id",
    }
)
_STATES: Final = frozenset({"submitted", "missed", "late_submitted"})
_DRAFT_LABELS: Final = frozenset({"미작성", "초안", "준비됨"})
_APPROVAL_LABELS: Final = frozenset({"요청 전", "검토 대기", "승인됨"})
_DELIVERY_LABELS: Final = frozenset({"미발송", "보류", "발송 확인"})


class Topic59PrivacyError(Exception):
    """An untrusted value cannot cross into the Topic-59 privacy boundary."""


def require_opaque_digest(value: str, reason: str) -> OpaqueProjectionDigest:
    if type(value) is not str or _DIGEST.fullmatch(value) is None:
        raise Topic59PrivacyError(reason)
    return OpaqueProjectionDigest(value)


def opaque_customer_id(value: str) -> OpaqueProjectionCustomerId:
    return OpaqueProjectionCustomerId(require_opaque_digest(value, "customer identity"))


def opaque_event_id(value: str | None) -> OpaqueProjectionEventId:
    return OpaqueProjectionEventId(_derive("source-event-id", value))


def opaque_status_id(value: str) -> OpaqueProjectionStatusId:
    if type(value) is not str:
        raise Topic59PrivacyError("status row id")
    if _DIGEST.fullmatch(value) is not None:
        return OpaqueProjectionStatusId(value)
    return OpaqueProjectionStatusId(_derive("status-row-id", value))


def topic59_payload_is_safe(value: Mapping[str, str | int]) -> bool:
    if frozenset(value) != _PAYLOAD_KEYS:
        return False
    customer = value["customer_identity_digest"]
    event = value["event_id"]
    status_row = value["status_row_id"]
    kst_day = value["kst_day"]
    state = value["state"]
    draft = value["draft_state"]
    approval = value["approval_state"]
    delivery = value["delivery_state"]
    count = value["completed_field_count"]
    if (
        not isinstance(customer, str)
        or not isinstance(event, str)
        or not isinstance(status_row, str)
        or not isinstance(kst_day, str)
        or not isinstance(state, str)
        or not isinstance(draft, str)
        or not isinstance(approval, str)
        or not isinstance(delivery, str)
    ):
        return False
    return (
        _DIGEST.fullmatch(customer) is not None
        and _DIGEST.fullmatch(event) is not None
        and _DIGEST.fullmatch(status_row) is not None
        and _is_kst_day(kst_day)
        and state in _STATES
        and type(count) is int
        and 0 <= count <= MAX_COMPLETED_FIELD_COUNT
        and draft in _DRAFT_LABELS
        and approval in _APPROVAL_LABELS
        and delivery in _DELIVERY_LABELS
    )


def is_kst_iso_day(value: str) -> bool:
    return _is_kst_day(value)


def _derive(domain: str, value: str | None) -> str:
    if value is not None and type(value) is not str:
        raise Topic59PrivacyError("source identifier")
    source = b"\xff" if value is None else value.encode("utf-8")
    material = TOPIC59_PROJECTION_SCHEMA.encode("utf-8") + b"\0" + domain.encode("utf-8") + b"\0" + source
    return hashlib.sha256(material).hexdigest()


def _is_kst_day(value: str) -> bool:
    match = re.fullmatch(r"(\d{4})-(\d{2})-(\d{2})", value)
    if match is None:
        return False
    year, month, day = (int(part) for part in match.groups())
    return 1 <= year <= 9999 and 1 <= month <= 12 and 1 <= day <= calendar.monthrange(year, month)[1]
