"""Lesson 4 lab: validate an API contract against its data model.

Run:
    python 04-api-data-model-design.py

This standard-library lab uses an in-memory SQLite database. It demonstrates
constraints, retry-safe creation, keyset pagination, state transitions, and a
versioned event envelope with deterministic assertions.
"""

from __future__ import annotations

import base64
import json
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any


SCHEMA = """
PRAGMA foreign_keys = ON;

CREATE TABLE notifications (
    notification_id TEXT PRIMARY KEY,
    tenant_id TEXT NOT NULL,
    recipient_id TEXT NOT NULL,
    channel TEXT NOT NULL CHECK (channel IN ('email', 'push', 'sms')),
    body TEXT NOT NULL CHECK (length(body) BETWEEN 1 AND 500),
    state TEXT NOT NULL CHECK (state IN ('accepted', 'delivering', 'delivered', 'failed')),
    created_at TEXT NOT NULL,
    updated_at TEXT NOT NULL
);

CREATE TABLE idempotency_requests (
    tenant_id TEXT NOT NULL,
    idempotency_key TEXT NOT NULL,
    request_fingerprint TEXT NOT NULL,
    notification_id TEXT NOT NULL REFERENCES notifications(notification_id),
    created_at TEXT NOT NULL,
    PRIMARY KEY (tenant_id, idempotency_key)
);

CREATE INDEX notifications_recipient_history
    ON notifications (tenant_id, recipient_id, created_at DESC, notification_id DESC);

CREATE INDEX notifications_retry_work
    ON notifications (tenant_id, updated_at, notification_id)
    WHERE state = 'failed';
"""

ALLOWED_TRANSITIONS = {
    "accepted": {"delivering"},
    "delivering": {"delivered", "failed"},
    "failed": {"delivering"},
    "delivered": set(),
}


@dataclass(frozen=True)
class CreateNotification:
    tenant_id: str
    recipient_id: str
    channel: str
    body: str
    idempotency_key: str

    def validate(self) -> None:
        required = {
            "tenant_id": self.tenant_id,
            "recipient_id": self.recipient_id,
            "idempotency_key": self.idempotency_key,
        }
        for name, value in required.items():
            if not value or not value.strip():
                raise ValueError(f"{name} is required")
        if self.channel not in {"email", "push", "sms"}:
            raise ValueError("channel must be email, push, or sms")
        if not 1 <= len(self.body) <= 500:
            raise ValueError("body must contain 1 to 500 characters")

    def fingerprint(self) -> str:
        canonical = json.dumps(
            {
                "recipient_id": self.recipient_id,
                "channel": self.channel,
                "body": self.body,
            },
            sort_keys=True,
            separators=(",", ":"),
        )
        return canonical


def connect() -> sqlite3.Connection:
    database = sqlite3.connect(":memory:")
    database.row_factory = sqlite3.Row
    database.executescript(SCHEMA)
    return database


def accept_notification(
    database: sqlite3.Connection,
    request: CreateNotification,
    notification_id: str,
    now: str,
) -> tuple[str, bool]:
    """Return (notification_id, created); retries reuse the first identity."""
    request.validate()
    fingerprint = request.fingerprint()

    with database:
        prior = database.execute(
            """
            SELECT notification_id, request_fingerprint
            FROM idempotency_requests
            WHERE tenant_id = ? AND idempotency_key = ?
            """,
            (request.tenant_id, request.idempotency_key),
        ).fetchone()
        if prior:
            if prior["request_fingerprint"] != fingerprint:
                raise ValueError("idempotency key was reused with a different request")
            return str(prior["notification_id"]), False

        database.execute(
            """
            INSERT INTO notifications
                (notification_id, tenant_id, recipient_id, channel, body, state,
                 created_at, updated_at)
            VALUES (?, ?, ?, ?, ?, 'accepted', ?, ?)
            """,
            (
                notification_id,
                request.tenant_id,
                request.recipient_id,
                request.channel,
                request.body,
                now,
                now,
            ),
        )
        database.execute(
            """
            INSERT INTO idempotency_requests
                (tenant_id, idempotency_key, request_fingerprint,
                 notification_id, created_at)
            VALUES (?, ?, ?, ?, ?)
            """,
            (
                request.tenant_id,
                request.idempotency_key,
                fingerprint,
                notification_id,
                now,
            ),
        )
    return notification_id, True


def encode_cursor(created_at: str, notification_id: str) -> str:
    raw = json.dumps([created_at, notification_id], separators=(",", ":")).encode()
    return base64.urlsafe_b64encode(raw).decode()


def decode_cursor(cursor: str) -> tuple[str, str]:
    try:
        created_at, notification_id = json.loads(
            base64.urlsafe_b64decode(cursor.encode()).decode()
        )
    except (ValueError, UnicodeDecodeError, json.JSONDecodeError) as error:
        raise ValueError("invalid cursor") from error
    if not isinstance(created_at, str) or not isinstance(notification_id, str):
        raise ValueError("invalid cursor")
    return created_at, notification_id


def list_recipient_history(
    database: sqlite3.Connection,
    tenant_id: str,
    recipient_id: str,
    limit: int,
    cursor: str | None = None,
) -> tuple[list[dict[str, Any]], str | None]:
    if not 1 <= limit <= 100:
        raise ValueError("limit must be between 1 and 100")

    parameters: list[Any] = [tenant_id, recipient_id]
    after = ""
    if cursor:
        created_at, notification_id = decode_cursor(cursor)
        after = "AND (created_at, notification_id) < (?, ?)"
        parameters.extend([created_at, notification_id])
    parameters.append(limit + 1)

    rows = database.execute(
        f"""
        SELECT notification_id, channel, state, created_at
        FROM notifications
        WHERE tenant_id = ? AND recipient_id = ? {after}
        ORDER BY created_at DESC, notification_id DESC
        LIMIT ?
        """,
        parameters,
    ).fetchall()
    has_more = len(rows) > limit
    page = rows[:limit]
    next_cursor = None
    if has_more and page:
        next_cursor = encode_cursor(page[-1]["created_at"], page[-1]["notification_id"])
    return [dict(row) for row in page], next_cursor


def transition(
    database: sqlite3.Connection,
    notification_id: str,
    target_state: str,
    now: str,
) -> None:
    row = database.execute(
        "SELECT state FROM notifications WHERE notification_id = ?",
        (notification_id,),
    ).fetchone()
    if row is None:
        raise LookupError("notification not found")
    current_state = str(row["state"])
    if target_state not in ALLOWED_TRANSITIONS[current_state]:
        raise ValueError(f"invalid transition: {current_state} -> {target_state}")
    with database:
        database.execute(
            "UPDATE notifications SET state = ?, updated_at = ? WHERE notification_id = ?",
            (target_state, now, notification_id),
        )


def event_for(row: sqlite3.Row) -> dict[str, Any]:
    """Create a small versioned envelope; consumers should ignore extra fields."""
    return {
        "specversion": "1.0",
        "type": "com.imazanwar.notification.state-changed.v1",
        "source": "/notification-service",
        "id": f"event-{row['notification_id']}-{row['state']}",
        "time": row["updated_at"],
        "subject": row["notification_id"],
        "data": {
            "tenant_id": row["tenant_id"],
            "notification_id": row["notification_id"],
            "state": row["state"],
        },
    }


def main() -> None:
    database = connect()
    times = [
        datetime(2026, 8, 22, 12, minute, tzinfo=UTC).isoformat()
        for minute in range(4)
    ]

    requests = [
        CreateNotification("acme", "user-7", "push", f"Message {number}", f"key-{number}")
        for number in range(3)
    ]
    for number, request in enumerate(requests):
        identity, created = accept_notification(
            database, request, f"notification-{number}", times[number]
        )
        assert identity == f"notification-{number}" and created

    identity, created = accept_notification(
        database, requests[0], "a-new-id-must-not-win", times[3]
    )
    assert identity == "notification-0" and not created
    assert database.execute("SELECT count(*) FROM notifications").fetchone()[0] == 3

    first_page, cursor = list_recipient_history(database, "acme", "user-7", limit=2)
    second_page, final_cursor = list_recipient_history(
        database, "acme", "user-7", limit=2, cursor=cursor
    )
    assert [item["notification_id"] for item in first_page] == [
        "notification-2",
        "notification-1",
    ]
    assert [item["notification_id"] for item in second_page] == ["notification-0"]
    assert final_cursor is None

    transition(database, "notification-0", "delivering", times[3])
    transition(database, "notification-0", "delivered", times[3])
    row = database.execute(
        "SELECT * FROM notifications WHERE notification_id = 'notification-0'"
    ).fetchone()
    event = event_for(row)
    assert event["data"]["state"] == "delivered"
    assert event["subject"] == "notification-0"

    print("API/data-model contract checks passed")
    print(json.dumps(event, indent=2))

    # Try it yourself:
    # 1. Add an expires_at policy for idempotency records and test key reuse.
    # 2. Insert a new notification between page requests and compare keyset
    #    pagination with an OFFSET-based query.
    # 3. Add a delivery_attempts table without making it authoritative for the
    #    notification's current state.


if __name__ == "__main__":
    main()
