"""Email capture: taking a subscription and exporting the list, with the personal-data rules.

Sources of truth: this module, `models/editorial.py` (the `subscribers` table). Called by
`routers/subscribers.py` (the one public write) and `cli_editorial.py` (the owner's export).

Rules, each of which is a test:
* The address is normalised (trimmed, lowercased) and checked for shape only; there is no
  double opt-in in this phase, so nothing is sent.
* A second submission of the same address updates the optional fields and the consent
  record, re-subscribes a withdrawn address, and answers exactly like a first one: the form
  never tells the person typing whether an address is already on the list.
* Nothing here logs an address. Log lines carry the row id and the source.
* The export is a CSV written by the CLI, owner-only by construction (the container's shell);
  it never goes through a route. Unsubscribed rows are left out unless asked for.
"""

from __future__ import annotations

import csv
import io
import re
from datetime import UTC, datetime

from sqlalchemy import select
from sqlalchemy.orm import Session

from app.models.editorial import Subscriber, SubscribeIn

# Shape only: something@something.tld, no whitespace, one @. Deliverability is the email
# platform's problem, not the form's.
_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]{2,}$")
_IATA = re.compile(r"^[A-Z]{3}$")
_INTEREST = re.compile(r"^[a-z0-9][a-z0-9 &/-]{0,39}$")

EXPORT_COLUMNS = (
    "email",
    "first_name",
    "last_name",
    "home_airport",
    "interests",
    "consent_at",
    "consent_text",
    "source",
    "created_at",
    "unsubscribed_at",
)


class InvalidSubscription(ValueError):
    """The form data cannot be stored; the message is safe to show the person."""


def normalise_email(raw: str) -> str:
    email = (raw or "").strip().lower()
    if len(email) > 320 or not _EMAIL.match(email):
        raise InvalidSubscription("That does not look like an email address.")
    return email


def clean_name(raw: str | None) -> str | None:
    text = re.sub(r"\s+", " ", (raw or "")).strip()
    return text[:80] or None


def clean_airport(raw: str | None) -> str | None:
    code = (raw or "").strip().upper()
    if not code:
        return None
    if not _IATA.match(code):
        raise InvalidSubscription("Home airport should be a three-letter airport code.")
    return code


def clean_interests(raw: list[str]) -> list[str]:
    seen: list[str] = []
    for item in raw[:12]:
        label = re.sub(r"\s+", " ", (item or "")).strip().lower()
        if label and _INTEREST.match(label) and label not in seen:
            seen.append(label)
    return seen


def subscribe(db: Session, payload: SubscribeIn, now: datetime | None = None) -> Subscriber:
    """Store or refresh a subscription. Raises InvalidSubscription for a bad address, a bad
    airport code, or missing consent."""
    if not payload.consent:
        raise InvalidSubscription("Please tick the box to confirm you would like to hear from us.")
    # Every field is checked before the session is touched: a refusal must leave nothing
    # half-built behind for the next commit to trip over (a test found exactly that).
    email = normalise_email(payload.email)
    fields = {
        "first_name": clean_name(payload.first_name),
        "last_name": clean_name(payload.last_name),
        "home_airport": clean_airport(payload.home_airport),
        "interests": clean_interests(payload.interests),
        "consent_at": now or datetime.now(UTC),
        "consent_text": payload.consent_text.strip()[:400],
        "source": (payload.source or "site").strip()[:40] or "site",
        "unsubscribed_at": None,
    }
    row = db.scalar(select(Subscriber).where(Subscriber.email == email))
    if row is None:
        row = Subscriber(email=email)
        db.add(row)
    for name, value in fields.items():
        setattr(row, name, value)
    try:
        db.commit()
    except Exception:
        db.rollback()
        raise
    return row


def unsubscribe(db: Session, email: str, now: datetime | None = None) -> bool:
    """Mark an address as withdrawn (the CLI does this on request). True when a row changed."""
    row = db.scalar(select(Subscriber).where(Subscriber.email == normalise_email(email)))
    if row is None or row.unsubscribed_at is not None:
        return False
    row.unsubscribed_at = now or datetime.now(UTC)
    db.commit()
    return True


def export_csv(db: Session, include_unsubscribed: bool = False) -> str:
    """The list as CSV text, oldest first. The caller decides where it goes; here it is only
    ever the owner's terminal or a file the owner named."""
    stmt = select(Subscriber).order_by(Subscriber.created_at, Subscriber.id)
    if not include_unsubscribed:
        stmt = stmt.where(Subscriber.unsubscribed_at.is_(None))
    out = io.StringIO()
    writer = csv.writer(out, lineterminator="\n")
    writer.writerow(EXPORT_COLUMNS)
    for s in db.scalars(stmt):
        writer.writerow(
            [
                s.email,
                s.first_name or "",
                s.last_name or "",
                s.home_airport or "",
                "; ".join(s.interests or []),
                _iso(s.consent_at),
                s.consent_text,
                s.source,
                _iso(s.created_at),
                _iso(s.unsubscribed_at),
            ]
        )
    return out.getvalue()


def stats(db: Session) -> dict[str, int]:
    """Counts only; safe to print anywhere."""
    rows = db.scalars(select(Subscriber)).all()
    active = [s for s in rows if s.unsubscribed_at is None]
    by_source: dict[str, int] = {}
    for s in active:
        by_source[s.source] = by_source.get(s.source, 0) + 1
    return {
        "total": len(rows),
        "active": len(active),
        "unsubscribed": len(rows) - len(active),
        **{f"source:{k}": v for k, v in sorted(by_source.items())},
    }


def _iso(value: datetime | None) -> str:
    return value.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ") if value else ""
