"""Editorial storage: articles as rows, and the people who asked to hear from us.

Sources of truth: this module and the Stream D migration in alembic/versions/ (build plan §4 #8,
Decision 8). Two tables, kept apart from the catalogue because nothing here is collected:
every row is written by a person, through `python -m app.cli articles import` or the
subscribe form.

* `articles` holds three kinds under one shape: `article` (its own page, listed on the home
  page), `airport_writeup` (shown on that airport's page, keyed by `airport_code`) and
  `category_intro` (shown on that category's page, keyed by `category`). The body is Markdown
  (`body_md`); HTML is rendered on read by `services/markdown.py` so the SPA and the
  crawler-facing body can never disagree. Public reads see `status = 'published'` only; a
  draft exists nowhere on the site, not even at its own address. `author_id` follows the
  who-columns rule (`FK accounts.id NULL`); `brand_id` points at the brands table from
  migration #3. `source_upload_id` remembers which client hand-in a row came from, so a
  re-import from the same file is a paste, not a mystery.
* `subscribers` is personal data and stores the minimum the client asked for (first name,
  last name, email, home airport, interests) plus the consent sentence the person saw and
  when. No IP address, no user agent. The email is stored lowercase and is unique; the CSV
  export is a CLI command run by the owner, never a route. `unsubscribed_at` keeps the row
  (a record that consent was withdrawn) while excluding it from every export by default.

The API shapes at the bottom are Pydantic; the frontend's TypeScript client is generated from
them like every other response model, so the SPA reads `body_html` and never parses Markdown.
"""

from datetime import datetime
from typing import TYPE_CHECKING

from pydantic import BaseModel, Field
from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship

from app.models.base import Base, TimestampMixin

if TYPE_CHECKING:
    from app.models.catalog import Account

ARTICLE_KINDS = ("article", "airport_writeup", "category_intro")
ARTICLE_STATUSES = ("draft", "published")


class Article(Base, TimestampMixin):
    """Editorial text of one of three kinds; `published` is the only state the site shows."""

    __tablename__ = "articles"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    slug: Mapped[str] = mapped_column(String(160), unique=True, nullable=False)
    title: Mapped[str] = mapped_column(String(200), nullable=False)
    standfirst: Mapped[str | None] = mapped_column(Text)
    body_md: Mapped[str] = mapped_column(Text, nullable=False)
    # article | airport_writeup | category_intro
    kind: Mapped[str] = mapped_column(
        String(20), nullable=False, index=True, default="article", server_default="article"
    )
    # Our own category name (products.category vocabulary), for category_intro rows.
    category: Mapped[str | None] = mapped_column(String(80), index=True)
    # IATA code, for airport_writeup rows.
    airport_code: Mapped[str | None] = mapped_column(String(4), index=True)
    brand_id: Mapped[int | None] = mapped_column(ForeignKey("brands.id"))
    author_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"))
    # draft | published
    status: Mapped[str] = mapped_column(
        String(12), nullable=False, index=True, default="draft", server_default="draft"
    )
    published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
    # A site-relative or https address; the file itself lives under public/ (Stream C's assets).
    hero_image: Mapped[str | None] = mapped_column(String(600))
    source_upload_id: Mapped[int | None] = mapped_column(
        ForeignKey("client_uploads.id", ondelete="SET NULL")
    )
    # The byline's account, read when a card asks for the name: only rows with an
    # author query it, and the session's identity map loads each author once.
    author: Mapped["Account | None"] = relationship(foreign_keys=[author_id])


class Subscriber(Base):
    """Someone who asked for the newsletter: the fields the client asked for, and their consent."""

    __tablename__ = "subscribers"

    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    # Lowercased before storing; the unique constraint is on this value.
    email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False)
    first_name: Mapped[str | None] = mapped_column(String(80))
    last_name: Mapped[str | None] = mapped_column(String(80))
    home_airport: Mapped[str | None] = mapped_column(String(4))
    # A short list of interest labels from the form's own vocabulary (e.g. ["whisky", "fragrance"]).
    interests: Mapped[list] = mapped_column(
        JSON().with_variant(JSONB(), "postgresql"),
        nullable=False,
        default=list,
        server_default="[]",
    )
    consent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
    # The exact sentence shown beside the checkbox at the time, so consent is provable later.
    consent_text: Mapped[str] = mapped_column(Text, nullable=False)
    # Where the form was: home | article | footer | airport ... (the form's `source` value).
    source: Mapped[str] = mapped_column(String(40), nullable=False)
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), nullable=False
    )
    unsubscribed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))


# --- API shapes -----------------------------------------------------------------------------


class ArticleSummary(BaseModel):
    """What a list or a teaser card needs; never the body."""

    slug: str
    path: str
    title: str
    standfirst: str | None = None
    #: The first words of the body when there is no standfirst, for a card.
    excerpt: str
    kind: str
    category: str | None = None
    airport_code: str | None = None
    hero_image: str | None = None
    #: The byline: the author account's display name, or None for the house
    #: byline (the SPA and the server body both print "Duty Free Professor").
    author: str | None = None
    #: Minutes to read the body at READING_WPM words a minute, at least one.
    reading_minutes: int = 1
    published_at: datetime | None = None
    updated_at: datetime


class ArticleOut(ArticleSummary):
    """The whole piece, with the body already rendered to safe HTML."""

    body_html: str
    #: Plain words of the body, cut short, for a description tag.
    description: str


class ArticlePage(BaseModel):
    total: int
    limit: int
    offset: int
    items: list[ArticleSummary]


class SubscribeIn(BaseModel):
    email: str = Field(min_length=3, max_length=320)
    first_name: str | None = Field(default=None, max_length=80)
    last_name: str | None = Field(default=None, max_length=80)
    home_airport: str | None = Field(default=None, max_length=4)
    interests: list[str] = Field(default_factory=list, max_length=12)
    #: Must be true; the sentence the person agreed to is stored with the timestamp.
    consent: bool = False
    consent_text: str = Field(min_length=10, max_length=400)
    source: str = Field(default="site", max_length=40)
    #: Honeypot. A person never sees this field; anything in it means a bot, and the request
    #: is answered as if it succeeded while nothing is stored.
    website: str | None = Field(default=None, max_length=200)


class SubscribeOut(BaseModel):
    """The same answer whether the address was new or already on the list: an existing
    subscriber is never disclosed to whoever typed the address."""

    ok: bool = True
    message: str
