from django.conf import settings
from django.db import models
from django.utils import timezone


FREE_DRAW_COOLDOWN_HOURS = 24

# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS.
# Used by `is_hand_complete` to decide whether the picker UX is in
# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled)
# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state.
HAND_SIZE_BY_SPREAD = {
    "past-present-future":      3,
    "situation-action-outcome": 3,
    "mind-body-spirit":         3,
    "desire-obstacle-solution": 3,
    "waite-smith":              6,
    "escape-velocity":          6,
}

# Position order per spread — mirrors `DRAW_ORDER` in my_sea.html JS
# (lines 274-281). The applet renders slots in this order left-to-right
# so drawn cards appear in chronological draw sequence regardless of
# their spread-grid placement.
DRAW_ORDER = {
    "past-present-future":      ("leave", "cover", "loom"),
    "situation-action-outcome": ("lay",   "cover", "crown"),
    "mind-body-spirit":         ("crown", "lay",   "loom"),
    "desire-obstacle-solution": ("loom",  "cross", "crown"),
    "waite-smith":              ("cover", "cross", "crown", "lay",   "loom",  "leave"),
    "escape-velocity":          ("cover", "cross", "lay",   "leave", "crown", "loom"),
}

# Per-spread human-readable labels for each position slug. Mirrors
# `POSITION_LABELS` in my_sea.html JS (lines 282-293). Escape Velocity
# remaps the diagonal positions per the user-locked spec 2026-05-19
# (Beneath→Lay, Before→Loom, Behind→Leave) — Crown/Cover/Cross keep
# their Waite-Smith names.
POSITION_LABELS = {
    "past-present-future":      {"leave": "Past",      "cover": "Present", "loom":  "Future"},
    "situation-action-outcome": {"lay":   "Situation", "cover": "Action",  "crown": "Outcome"},
    "mind-body-spirit":         {"crown": "Mind",      "lay":   "Body",    "loom":  "Spirit"},
    "desire-obstacle-solution": {"loom":  "Desire",    "cross": "Obstacle","crown": "Solution"},
    "waite-smith":              {"crown": "Crown",     "leave": "Behind",  "cover": "Cover",
                                 "cross": "Cross",     "loom":  "Before",  "lay":   "Beneath"},
    "escape-velocity":          {"crown": "Crown",     "leave": "Leave",   "cover": "Cover",
                                 "cross": "Cross",     "loom":  "Loom",    "lay":   "Lay"},
}


def latest_draw_slots(user):
    """Build the slot list for the My Sea applet — pairs each draw-order
    position w. its drawn card (if any) + display label + polarity.

    Drives `_applet-my-sea.html`: the applet renders ALL positions in
    spread DRAW_ORDER, filled slots showing the drawn card, empty slots
    rendering as a labelled placeholder w. --duoUser bg + --terUser
    dotted border (user spec 2026-05-22). Spread is locked at first
    card draw + survives DEL'd-but-not-expired rows... but the applet
    only populates while `hand` is non-empty: a DEL'd row reads as
    "No draws yet" since the user has explicitly cleared their picks.

    Returns:
        list[dict] — one entry per spread position in draw order:
            {
                "position": "lay",       # position slug
                "label":    "Situation", # spread-specific human label
                "card":     <TarotCard>, # None if not yet drawn
                "reversed": bool,        # False if not yet drawn
                "polarity": "gravity",   # "" if not yet drawn
                "face":     dict | None, # applet_face() payload — see TarotCard
            }
        Empty list = no active draw OR active draw w. empty hand.
    """
    draw = active_draw_for(user)
    if draw is None or not draw.hand:
        return []
    from apps.epic.models import TarotCard
    order = DRAW_ORDER.get(draw.spread, ())
    labels = POSITION_LABELS.get(draw.spread, {})
    by_position = {entry["position"]: entry for entry in draw.hand}
    card_ids = {
        entry["card_id"] for entry in draw.hand
        if entry.get("card_id")
    }
    cards_by_id = (
        {c.id: c for c in TarotCard.objects.filter(id__in=card_ids)}
        if card_ids else {}
    )
    slots = []
    for pos in order:
        entry = by_position.get(pos)
        card = cards_by_id.get(entry["card_id"]) if entry else None
        reversed_flag = entry.get("reversed", False) if entry else False
        polarity = entry.get("polarity", "gravity") if entry else ""
        slots.append({
            "position": pos,
            "label":    labels.get(pos, ""),
            "card":     card,
            "reversed": reversed_flag,
            "polarity": polarity,
            "face":     (card.applet_face(polarity or "gravity", reversed_flag)
                         if card else None),
        })
    return slots


class MySeaDraw(models.Model):
    """Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
    feature. Each row is the user's active draw — both the hand state
    AND the 24h quota tracker.

    Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
    iter-4b LOCK-HAND model. The row is created on the FIRST card draw
    (manual or AUTO DRAW), not on a separate LOCK action. Quota is
    committed at first-card moment + survives a DEL (DEL clears the
    `hand` JSON but preserves the row — so `created_at` keeps running
    the 24h clock + landing renders GATE VIEW instead of FREE DRAW
    until the row expires).

    `hand` is an ordered list of position-dicts in draw order — Sprint 7's
    applet renders them left-to-right in that order. Each entry shape:

        {"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}

    For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD).
    For DEL'd rows, `hand` is `[]` but `created_at` still anchors the
    quota window.

    `significator_id` + `significator_reversed` snapshot the user's sig
    at first-card-draw time so a subsequent `User.significator = None`
    (via my-sign DEL) doesn't invalidate the saved draw.
    """
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="my_sea_draws",
    )
    spread = models.CharField(max_length=40)
    hand = models.JSONField(default=list)
    significator_id = models.IntegerField()
    significator_reversed = models.BooleanField(default=False)
    created_at = models.DateTimeField(default=timezone.now)
    # Sprint 5 iter 6a — gatekeeper deposit lifecycle. A token can be
    # RESERVED on the row (refundable until the user clicks PAID DRAW)
    # via the my-sea gatekeeper at /gameboard/my-sea/gate/. PAID DRAW
    # commits the deposit + resets the row (`hand=[]`, `created_at=now`,
    # both deposit_* fields back to None), starting a fresh 24h quota
    # cycle paid for by the deposited token. See `_select_my_sea_token`
    # + `debit_my_sea_token` for the priority chain + per-type rules.
    deposit_token_id = models.IntegerField(null=True, blank=True)
    deposit_reserved_at = models.DateTimeField(null=True, blank=True)
    # PAID DRAW credit marker — set when `my_sea_paid_draw` commits the
    # deposited token. Stays sticky until the credit is consumed by the
    # first card-draw (cleared in `my_sea_lock`) OR the row expires.
    # Drives the landing-button state: a row w. `paid_through_at` set +
    # `hand=[]` renders the PAID DRAW button (navigates to picker, no new
    # token spent), so a user who pays + navigates away before drawing
    # still sees their paid state preserved (user-reported bug 2026-05-23
    # — without this field, the row was deleted at commit time + the
    # landing fell through to FREE DRAW on next page load).
    paid_through_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return f"MySeaDraw({self.user_id}, {self.spread}, {self.created_at:%Y-%m-%d %H:%M})"

    @property
    def next_free_draw_at(self):
        """Datetime when the user's next free draw will be available
        (created_at + FREE_DRAW_COOLDOWN_HOURS)."""
        return self.created_at + timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)

    @property
    def is_within_quota_window(self):
        """True iff this draw was created within the last
        FREE_DRAW_COOLDOWN_HOURS — i.e., it currently occupies the user's
        free-draw slot."""
        return self.created_at >= (
            timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
        )

    @property
    def is_hand_complete(self):
        """True iff the hand has filled every position for its spread.
        Drives the picker-form-col button state (AUTO DRAW → GATE VIEW
        + DEL enable + FLIPs disable on the deck stacks)."""
        return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0)

    @property
    def is_hand_empty(self):
        """True iff the hand carries no entries — the post-DEL state,
        where the row stays as a quota tracker but the cards are gone."""
        return not self.hand

    @classmethod
    def delete_stale(cls):
        """Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS.
        Returns the deletion count.

        Called lazily from `active_draw_for` on every view access (so
        the cleanup naturally rides user traffic — no scheduler needed
        for the common case) AND from the `delete_stale_my_sea_draws`
        management command (cron backstop for inactive periods)."""
        cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
        deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete()
        return deleted


def _select_my_sea_token(user):
    """Token-picker for the my-sea gatekeeper. Same equip-gated semantics
    as `apps.epic.models.select_token` (trinket must be DON-ed) w. two
    my-sea-specific rules:

    - **CARTE excluded.** Even equipped, CARTE never auto-picks here —
      `debit_my_sea_token` would raise ValueError. Fall through to FREE.
    - **COIN cooldown-respecting.** After a my-sea PAID DRAW debits a
      COIN, `debit_my_sea_token` sets `next_ready_at = now + 24h`. A
      cooldown'd COIN — even equipped — falls through to FREE. The
      cooldown is my-sea-specific; room `select_token` ignores it.

    No equipped trinket (or equipped one invalid for this gate) → FREE
    (FEFO) → TITHE → None. See [[feedback-equip-slot-gates-trinket-use]].
    """
    from apps.lyric.models import Token
    now = timezone.now()
    # Query fresh from user's tokens (not via cached FK descriptor) — see
    # `apps.epic.models.select_token` for the rationale.
    if user.equipped_trinket_id is not None:
        trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
    else:
        trinket = None
    if trinket is not None:
        if trinket.token_type == Token.PASS and user.is_staff:
            return trinket
        if trinket.token_type == Token.BAND:
            return trinket
        if trinket.token_type == Token.COIN:
            coin_ready = (
                trinket.current_room_id is None
                and (trinket.next_ready_at is None or trinket.next_ready_at <= now)
            )
            if coin_ready:
                return trinket
        # CARTE excluded — debit_my_sea_token would raise.
    free = user.tokens.filter(
        token_type=Token.FREE, expires_at__gt=now,
    ).order_by("expires_at").first()
    if free:
        return free
    return user.tokens.filter(token_type=Token.TITHE).first()


def debit_my_sea_token(user, token):
    """Commit a my-sea-deposited token. Adapted from `apps.epic.models.
    debit_token` for solo my-sea per user spec 2026-05-20:

    - **CARTE → ValueError.** Defensive guard; caller should validate
      upstream via `_select_my_sea_token`.
    - **COIN.** No `current_room` (there is no room context); instead,
      set `next_ready_at = now + 24h` so the next-pickup gate kicks in.
      Unequip from the kit if equipped (parity w. room's COIN behavior).
    - **PASS.** No consumption — auto-admit. Stays equipped.
    - **FREE / TITHE.** Consumed (deleted).
    """
    from datetime import timedelta
    from apps.lyric.models import Token
    if token.token_type == Token.CARTE:
        raise ValueError("CARTE cannot pay for a my-sea draw")
    if token.token_type == Token.COIN:
        token.next_ready_at = timezone.now() + timedelta(hours=24)
        token.save(update_fields=["next_ready_at"])
        if user.equipped_trinket_id == token.pk:
            user.equipped_trinket = None
            user.save(update_fields=["equipped_trinket"])
    elif token.token_type in (Token.PASS, Token.BAND):
        pass  # auto-admit trinkets — never consumed, stay equipped
    else:
        token.delete()


def active_draw_for(user):
    """Return the user's most-recent draw within the quota window, or
    None. Single source of truth for "does the user have an active draw"
    (drives view branching: no-active → landing w. FREE DRAW; active w.
    empty hand → landing w. GATE VIEW; active w. non-empty hand → picker).

    Lazy stale-row cleanup: every call prunes the user's >24h rows, so
    the DB doesn't accumulate one row per user per day. The user-spec
    'auto-delete all draws after 24hrs, whether or not the user has
    deleted them' (2026-05-20) lands here w. no scheduler required.

    Cooldown for FREE-DRAW-button rendering is separately tracked at the
    User level (`User.last_free_draw_at`) — see [[feedback-cooldown-
    anchored-to-free-draw]]. This function is purely about row TTL.
    """
    cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
    MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
    return MySeaDraw.objects.filter(
        user=user, created_at__gte=cutoff,
    ).order_by("-created_at").first()
