"""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, Boolean, DateTime, ForeignKey, Integer, String, Text, func
from sqlalchemy import text as sa_text
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, Brand

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))
    # The attribution shown under the hero. A supplied picture is licensed, not ours, so the
    # credit rides with the address: no credit, no permission.
    hero_credit: Mapped[str | None] = mapped_column(String(200))
    # Written by the Professor to show the client what an article centre looks like, never
    # by the client. `articles unpublish --samples` is the launch plan's one step for these.
    sample: Mapped[bool] = mapped_column(
        Boolean, nullable=False, default=False, server_default=sa_text("false")
    )
    # Short lowercase labels the article centre filters on (`/articles?tag=`), cleaned to the
    # same shape as a subscriber's interests.
    tags: Mapped[list] = mapped_column(
        JSON().with_variant(JSONB(), "postgresql"),
        nullable=False,
        default=list,
        server_default="[]",
    )
    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])
    # The brand a piece is about, when it is about one: the article centre links its page
    # and the piece shows a few of its product lines. A row with no brand loads nothing
    # (a many-to-one over a null key never queries), which is almost every row.
    brand: Mapped["Brand | None"] = relationship(foreign_keys=[brand_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 attribution printed under the hero picture, when the picture needs one.
    hero_credit: str | None = None
    #: A piece the Professor wrote to show what an article centre looks like. Marked on its
    #: card and at the top of its page, and taken to draft before production is replaced.
    sample: bool = False
    #: Short lowercase labels; the article centre filters on them.
    tags: list[str] = Field(default_factory=list)
    #: The brand a piece is about, when it is about one: the slug is its page's address.
    brand_slug: str | None = None
    brand_name: str | None = None
    #: The category page the kicker leads to, when the kicker is one of our category names
    #: rather than a section word ("Whisky" has a page, "Buying guides" does not).
    category_path: str | None = None
    #: The byline: the author account's display name, or None for the brand
    #: 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]
    #: Every tag in use across the published pieces of this kind, not just this page's, so
    #: the article centre's filter rail is the whole vocabulary however deep the reader is.
    tags: list[str] = Field(default_factory=list)
    #: The tag this page is filtered to, or None. The server renders the filtered grid and
    #: seeds it, so the SPA draws the same list it was served.
    tag: str | None = None


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
