import uuid

from datetime import timedelta
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.urls import reverse
from django.utils import timezone


# ── Pronoun preference set ────────────────────────────────────────────────
# Drives provenance prose ("embodies as their Significator …") and any other
# user-facing referent. Default is pluralism (singular they) so a brand-new
# account renders neutrally; bawlmorese (yo/yo/yos) is the original Earthman
# default kept available as a Baltimore-flavoured option.

PRONOUN_CHOICES = [
    ("pluralism",   "they/them/their"),
    ("bawlmorese",  "yo/yo/yos"),
    ("misogyny",    "he/him/his"),
    ("misandry",    "she/her/hers"),
    ("misanthropy", "it/it/its"),
]
PRONOUN_TABLE = {
    "pluralism":   {"subj": "they", "obj": "them", "poss": "their"},
    "bawlmorese":  {"subj": "yo",   "obj": "yo",   "poss": "yos"},
    "misogyny":    {"subj": "he",   "obj": "him",  "poss": "his"},
    "misandry":    {"subj": "she",  "obj": "her",  "poss": "hers"},
    "misanthropy": {"subj": "it",   "obj": "it",   "poss": "its"},
}


def resolve_pronouns(pronouns_key):
    """Return (subj, obj, poss) for a pronouns key, defaulting to pluralism."""
    row = PRONOUN_TABLE.get(pronouns_key) or PRONOUN_TABLE["pluralism"]
    return row["subj"], row["obj"], row["poss"]


# ── Reserved usernames ────────────────────────────────────────────────────
# Sitewide entities that shouldn't be impersonated by new account names.
# Compared lower-case in username assignment paths (set_profile, etc.).
# `adman` is the system author for Note-unlock + share-invite Lines (seeded
# in lyric/0003_seed_adman). The author's handles (disco, discoman,
# hamildong) are NOT in this set yet — discoman is the founder's actual
# username and existing tests assign it; revisit if/when other-entity
# impersonation becomes a concrete concern.

RESERVED_USERNAMES = frozenset({"adman"})


def is_reserved_username(name, current_user=None):
    """True if `name` is reserved AND not already owned by `current_user`."""
    n = (name or "").strip().lower()
    if not n:
        return False
    if current_user is not None and (current_user.username or "").lower() == n:
        return False
    return n in RESERVED_USERNAMES


def get_or_create_adman():
    """Idempotent fetch of the sitewide `adman` User — system-author for
    Note-unlock + share-invite Lines. Production migrations seed it once
    (lyric/0003_seed_adman); TransactionTestCase flushes the row between
    tests, so view code that authors Lines as adman calls this helper."""
    from django.contrib.auth.hashers import make_password
    adman, _ = User.objects.get_or_create(
        username="adman",
        defaults={
            "email": "adman@earthmanrpg.local",
            "password": make_password(None),
            "is_staff": False,
            "is_superuser": False,
            "searchable": False,
        },
    )
    return adman


class UserManager(BaseUserManager):
    def create_user(self, email):
        user = self.model(email=email)
        user.set_unusable_password()
        user.save(using=self._db)
        return user

    def create_superuser(self, email, password):
        user = self.model(email=email, is_staff=True, is_superuser=True)
        user.set_password(password)
        user.save(using=self._db)
        return user

class LoginToken(models.Model):
    email = models.EmailField()
    uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

class User(AbstractBaseUser):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    email = models.EmailField(unique=True)
    username = models.CharField(max_length=35, unique=True, null=True, blank=True)
    searchable = models.BooleanField(default=False)
    palette = models.CharField(max_length=32, default="palette-default")
    stripe_customer_id = models.CharField(max_length=255, null=True, blank=True)
    equipped_trinket = models.ForeignKey(
        "Token", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="+",
    )
    equipped_deck = models.ForeignKey(
        "epic.DeckVariant", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="+",
    )
    unlocked_decks = models.ManyToManyField(
        "epic.DeckVariant", blank=True, related_name="unlocked_by",
    )
    # Asymmetric self M2M — `user.buds.all()` = people I've explicitly
    # added (or implicitly via post-share / game-invite, which auto-adds
    # the recipient to the inviter's buds list). `user.added_as_bud` is
    # the inverse (people who have me in their buds list); useful for
    # the future "bud changed username" snapshot-accept flow.
    buds = models.ManyToManyField(
        "self", symmetrical=False, blank=True, related_name="added_as_bud",
    )
    active_title = models.ForeignKey(
        "drama.Note", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="+",
    )
    # Global personal significator — chosen at /billboard/my-sig/ + persisted
    # for reuse across My Sea draws (and eventually other contexts). Single
    # FK; the orientation in `significator_reversed` (FLIP btn in the picker
    # carousel) determines polarity at draw time.
    significator = models.ForeignKey(
        "epic.TarotCard", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="+",
    )
    significator_reversed = models.BooleanField(default=False)
    # My Sea free-draw cooldown anchor — the timestamp of the user's most
    # recent TOKENLESS first-card-draw of a 24h cycle. Set when the user
    # creates a MySeaDraw row via the FREE DRAW path (button on the my-sea
    # landing); PAID DRAW deliberately does NOT update it, so the next
    # free draw is always anchored to the original free-draw moment, not
    # the most recent paid one (user-spec 2026-05-23). Drives the Brief
    # banner's next-free-draw timestamp + the landing-button state machine
    # (FREE DRAW vs GATE VIEW vs PAID DRAW).
    last_free_draw_at = models.DateTimeField(null=True, blank=True)
    ap_public_key = models.TextField(blank=True, default="")
    ap_private_key = models.TextField(blank=True, default="")

    # Personal natal chart (My Sky) — independent of any game room/character
    sky_birth_dt    = models.DateTimeField(null=True, blank=True)
    sky_birth_lat   = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
    sky_birth_lon   = models.DecimalField(max_digits=9, decimal_places=4, null=True, blank=True)
    sky_birth_place = models.CharField(max_length=255, blank=True)
    sky_birth_tz    = models.CharField(max_length=64,  blank=True)
    sky_house_system = models.CharField(max_length=1, blank=True, default="O")
    sky_chart_data  = models.JSONField(null=True, blank=True)

    is_staff = models.BooleanField(default=False)
    is_superuser = models.BooleanField(default=False)

    pronouns = models.CharField(
        max_length=16, choices=PRONOUN_CHOICES, default="pluralism",
    )

    objects = UserManager()
    REQUIRED_FIELDS = []
    USERNAME_FIELD = "email"

    # ── My Sea free-draw cooldown helpers ────────────────────────────────
    # Pair w. `last_free_draw_at` above. The cooldown anchors to the FREE
    # DRAW moment (NOT to any subsequent paid draws), so the Brief banner
    # surfaces "next free draw at" relative to the user's actual cycle
    # start. PAID DRAWs commit their tokens against this same cooldown
    # window — they don't reset it.

    @property
    def sig_face(self):
        """Rendering payload for the saved sig in `_applet-my-sign.html`.
        `significator_reversed` is the POLARITY axis (FLIP — True ↔ levity,
        per [[feedback-significator-reversed-is-polarity]]); the SPIN /
        orientation axis is never persisted, so the saved sig is always
        rendered upright in its polarity (reversed=False to `applet_face`).
        Returns `None` when no sig is saved."""
        if self.significator is None:
            return None
        polarity = 'levity' if self.significator_reversed else 'gravity'
        return self.significator.applet_face(polarity, reversed=False)

    @property
    def free_draw_cooldown_active(self):
        """True iff the user is currently inside the 24h cooldown window
        triggered by their last tokenless free draw. False for fresh users
        (never free-drew) and for users whose cooldown has elapsed."""
        from django.utils import timezone
        from datetime import timedelta
        if self.last_free_draw_at is None:
            return False
        return self.last_free_draw_at + timedelta(hours=24) > timezone.now()

    @property
    def next_free_draw_at(self):
        """Datetime when the user's next free draw becomes available
        (`last_free_draw_at + 24h`). Returns None if the user has never
        free-drawn."""
        from datetime import timedelta
        if self.last_free_draw_at is None:
            return None
        return self.last_free_draw_at + timedelta(hours=24)

    @property
    def active_title_display(self):
        """Render-ready string for "{username} the {title}" attributions —
        returns the donned Note's `display_name`, or 'Earthman' when no
        Note is donned. Uses `display_name` (not `display_title`) so the
        Baltimorean rename — `display_title="Ard!"` (navbar DON greeting
        flair only) vs. `display_name="Baltimorean"` (everywhere else) —
        propagates to inline attributions like `.post-attribution`. For
        non-overridden slugs the two are equal so this is a no-op."""
        if self.active_title_id:
            return self.active_title.display_name
        return "Earthman"

    @property
    def pronoun_subj(self):
        return resolve_pronouns(self.pronouns)[0]

    @property
    def pronoun_obj(self):
        return resolve_pronouns(self.pronouns)[1]

    @property
    def pronoun_poss(self):
        return resolve_pronouns(self.pronouns)[2]

    def ensure_keypair(self):
        """Generate and persist an RSA-2048 keypair if not already set."""
        if self.ap_public_key:
            return
        from cryptography.hazmat.primitives.asymmetric import rsa
        from cryptography.hazmat.primitives import serialization
        private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
        self.ap_public_key = private_key.public_key().public_bytes(
            serialization.Encoding.PEM,
            serialization.PublicFormat.SubjectPublicKeyInfo,
        ).decode()
        self.ap_private_key = private_key.private_bytes(
            serialization.Encoding.PEM,
            serialization.PrivateFormat.PKCS8,
            serialization.NoEncryption(),
        ).decode()
        self.save(update_fields=["ap_public_key", "ap_private_key"])

    def has_perm(self, perm, obj=None):
        return self.is_superuser
    
    def has_module_perms(self, app_label):
        return self.is_superuser
    
class Wallet(models.Model):
    user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="wallet")
    writs = models.IntegerField(default=0)
    esteem = models.IntegerField(default=0)

    def tooltip_name(self):
        return "Wallet"

    def tooltip_description(self):
        return f"{self.writs} writs · {self.esteem} esteem"

    def tooltip_shoptalk(self):
        return None

    def tooltip_expiry(self):
        return None

    def tooltip_text(self):
        return f"{self.tooltip_name()}: {self.tooltip_description()}"

class Token(models.Model):
    COIN = "coin"
    FREE = "Free"
    TITHE = "tithe"
    PASS = "pass"
    BAND = "band"
    CARTE = "carte"
    TOKEN_TYPE_CHOICES = [
        (COIN, "Coin-on-a-String"),
        (FREE, "Free Token"),
        (TITHE, "Tithe Token"),
        (PASS, "Backstage Pass"),
        (BAND, "Wristband"),
        (CARTE, "Carte Blanche"),
    ]

    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="tokens")
    token_type = models.CharField(max_length=8, choices=TOKEN_TYPE_CHOICES)
    expires_at = models.DateTimeField(null=True, blank=True)
    current_room = models.ForeignKey(
        "epic.Room", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="coin_tokens"
    )
    next_ready_at = models.DateTimeField(null=True, blank=True)
    slots_claimed = models.PositiveSmallIntegerField(default=0, blank=True)

    def clean(self):
        # PASS is admin-only — game-side surfaces (gameboard, game-kit, gate
        # picker) all filter PASS behind user.is_staff, so a non-staff PASS
        # row is invisible/unusable and just clutters the wallet. A non-admin
        # variant ("Boost Pass" or similar) will land as a distinct token_type
        # later — keep the rule strict here so the two never blur.
        super().clean()
        if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
            raise ValidationError(
                {"token_type": "PASS is admin-only — staff users only."}
            )

    def save(self, *args, **kwargs):
        if self.token_type == self.PASS and self.user_id and not self.user.is_staff:
            raise ValidationError(
                {"token_type": "PASS is admin-only — staff users only."}
            )
        super().save(*args, **kwargs)

    def tooltip_name(self):
        return self.get_token_type_display()
    
    def tooltip_description(self):
        if self.token_type in (self.COIN, self.FREE):
            return "Admit 1 Entry"
        if self.token_type in (self.PASS, self.BAND):
            return "Admit All Entry"
        if self.token_type == self.TITHE:
            return "+ Writ bonus"
        if self.token_type == self.CARTE:
            return "Admit up to +6"
        return ""

    def tooltip_expiry(self):
        if self.token_type in (self.COIN, self.PASS, self.BAND, self.CARTE):
            if self.token_type == self.COIN and self.next_ready_at:
                return f"Ready {self.next_ready_at.strftime('%Y-%m-%d')}"
            return "no expiry"
        if self.expires_at:
            return f"Expires {self.expires_at.strftime('%Y-%m-%d')}"
        return ""
    
    def tooltip_room_html(self):
        if not self.current_room_id:
            return ""
        url = reverse("epic:gatekeeper", kwargs={"room_id": self.current_room_id})
        return f'<a href="{url}">{self.current_room.name}</a>'

    def tooltip_shoptalk(self):
        if self.token_type == self.COIN:
            return "\u2026and another after that, and another after that\u2026"
        if self.token_type == self.FREE:
            return "a spot of good fortune"
        if self.token_type == self.PASS:
            return "\u2018Entry fee\u2019? Pal, do you know who you\u2019re talking to?"
        if self.token_type == self.BAND:
            return "Unlimited free entry (BYOB)"
        if self.token_type == self.CARTE:
            return "No, I\u2019m afraid we\u2019ll be taking over from here."
        return None

    def tooltip_text(self):
        text = f"{self.tooltip_name()}: {self.tooltip_description()}"
        if self.tooltip_shoptalk():
            text += f" ({self.tooltip_shoptalk()})"
        text += f" \u2014 {self.tooltip_expiry()}"
        return text
    
class PaymentMethod(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="payment_methods")
    stripe_pm_id = models.CharField(max_length=255)
    last4 = models.CharField(max_length=4)
    brand = models.CharField(max_length=32)

    def __str__(self):
        return f"{self.brand} ....{self.last4}"


class ShopItem(models.Model):
    """A purchasable bundle in the wallet's Shop applet — admin-managed
    catalog. Each row defines (price → granted Tokens + writs); the
    `Purchase.fulfill()` flow mints `granted_count` tokens of
    `granted_token_type` + bumps `Wallet.writs` by `granted_writs`.

    See [[project-wallet-shop-expansion]] for the broader design + the
    3 starting catalog items (tithe-1, tithe-5, band-1) seeded in
    `lyric/0009_seed_shop_items`."""

    slug = models.SlugField(unique=True)
    name = models.CharField(max_length=100)
    description = models.TextField(blank=True, default="")
    # `shoptalk` is the italic flavor line that mirrors `Token.tooltip_shoptalk` —
    # rendered via the `.tt-shoptalk` SCSS class (DRY w. the wallet's Token row).
    # Blank → the `{% if item.shoptalk %}` slot in the template is skipped.
    shoptalk = models.CharField(max_length=200, blank=True, default="")
    icon = models.CharField(max_length=50)            # FA icon class (eg "fa-piggy-bank")
    badge_text = models.CharField(max_length=8, blank=True, default="")  # eg "×5"; "" = no badge
    price_cents = models.PositiveIntegerField()
    granted_token_type = models.CharField(
        max_length=8, choices=Token.TOKEN_TYPE_CHOICES,
    )
    granted_count = models.PositiveSmallIntegerField(default=1)
    granted_writs = models.PositiveIntegerField(default=0)
    # `max_owned=None` → unlimited stock per user. `max_owned=1` → BAND-style
    # "you can only have one of these" — the shop UI disables BUY w. an
    # "Already owned" microtooltip when the user's owned-count of the granted
    # token type has reached this cap.
    max_owned = models.PositiveSmallIntegerField(null=True, blank=True)
    display_order = models.PositiveSmallIntegerField(default=100)
    active = models.BooleanField(default=True)

    class Meta:
        ordering = ["display_order", "slug"]

    def __str__(self):
        return self.name

    def is_available_for(self, user):
        """True iff the user can purchase another of this item right now.
        Honors `max_owned` (compares to user's owned-count of the granted
        token type). Items w. `max_owned=None` are always available."""
        if self.max_owned is None:
            return True
        owned = user.tokens.filter(token_type=self.granted_token_type).count()
        return owned < self.max_owned

    def price_display(self):
        """Render-ready dollar string for tooltips. Cents trimmed for whole
        dollars; otherwise two decimals."""
        dollars = self.price_cents / 100
        if dollars == int(dollars):
            return f"${int(dollars)}"
        return f"${dollars:.2f}"

    def tooltip_expiry(self):
        """All shop items are eternal stock (no time-bound listings yet) so
        the tooltip's `.tt-expiry` slot always shows 'no expiry' — same
        red-callout styling as PASS/BAND/CARTE token tooltips. If a future
        seasonal item needs a real expiry, override on the row + return
        the formatted string here."""
        return "no expiry"


class Purchase(models.Model):
    """Audit-trail row for one shop transaction. Created at PENDING on
    Stripe PaymentIntent creation, advanced to SUCCEEDED via `fulfill()`
    (called from EITHER the synchronous `/shop/confirm` view OR the
    `/stripe/webhook` handler — whichever wins). `fulfill()` is idempotent
    so the race is harmless.

    `granted_token_ids` snapshots the PKs of every Token row this purchase
    minted so we can audit / refund / rebuild later without re-deriving
    from `created_at`."""

    PENDING = "PENDING"
    SUCCEEDED = "SUCCEEDED"
    FAILED = "FAILED"
    REFUNDED = "REFUNDED"
    STATUS_CHOICES = [
        (PENDING, "Pending"),
        (SUCCEEDED, "Succeeded"),
        (FAILED, "Failed"),
        (REFUNDED, "Refunded"),
    ]

    user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="purchases")
    shop_item = models.ForeignKey(ShopItem, on_delete=models.PROTECT, related_name="purchases")
    stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
    amount_cents = models.PositiveIntegerField()       # snapshot — ShopItem price may change later
    granted_writs = models.PositiveIntegerField(default=0)  # snapshot
    granted_token_ids = models.JSONField(default=list, blank=True)
    created_at = models.DateTimeField(auto_now_add=True)
    succeeded_at = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return f"Purchase({self.user_id}, {self.shop_item.slug}, {self.status})"

    def fulfill(self):
        """Mint tokens + grant writs. Idempotent — re-firing on a row that's
        already SUCCEEDED is a safe no-op (the webhook + the sync
        `/shop/confirm` view both call this; whichever lands first wins).

        Failures elsewhere shouldn't reach this method — `status=FAILED`
        rows stay FAILED + don't fulfill."""
        from django.db import transaction
        if self.status == self.SUCCEEDED:
            return
        if self.status not in (self.PENDING,):
            # FAILED / REFUNDED — refuse to fulfill.
            return
        item = self.shop_item
        with transaction.atomic():
            granted_ids = []
            for _ in range(item.granted_count):
                t = Token.objects.create(
                    user=self.user,
                    token_type=item.granted_token_type,
                )
                granted_ids.append(t.pk)
            if self.granted_writs:
                wallet = self.user.wallet
                wallet.writs = wallet.writs + self.granted_writs
                wallet.save(update_fields=["writs"])
            self.granted_token_ids = granted_ids
            self.status = self.SUCCEEDED
            self.succeeded_at = timezone.now()
            self.save(update_fields=[
                "granted_token_ids", "status", "succeeded_at",
            ])
    
@receiver(post_save, sender=User)
def create_wallet_and_tokens(sender, instance, created, **kwargs):
    if not created:
        return
    from apps.epic.models import DeckVariant
    Wallet.objects.create(user=instance, writs=144)
    coin = Token.objects.create(user=instance, token_type=Token.COIN)
    Token.objects.create(
        user=instance,
        token_type=Token.FREE,
        expires_at=timezone.now() + timedelta(days=7),
    )
    if instance.is_staff:
        pass_token = Token.objects.create(user=instance, token_type=Token.PASS)
        instance.equipped_trinket = pass_token
    else:
        instance.equipped_trinket = coin
    earthman = DeckVariant.objects.filter(slug="earthman").first()
    instance.equipped_deck = earthman
    instance.save(update_fields=['equipped_trinket', 'equipped_deck'])
    if earthman:
        instance.unlocked_decks.add(earthman)
    if instance.is_superuser:
        from apps.drama.models import Note
        Note.grant_if_new(instance, "super-schizo")
        Note.grant_if_new(instance, "super-nomad")
