"""Articles: reading a hand-in into a row, and the published reads every page uses.

Sources of truth: this module, `models/editorial.py`, `services/markdown.py`. Called by
`cli_editorial.py` (import, publish) and `routers/articles.py` (public reads); Stream B's
server-rendered pages call the same read functions so a crawler and the SPA see one text.

Intake is `read_document()`: a Markdown or plain-text file is taken as written; a Word file
is converted to Markdown by `mammoth` (pure Python, pinned in pyproject.toml). A leading
`# Title` (or Word's Title / Heading 1 style) becomes the title and leaves the body; the
slug is derived from it unless given. Embedded Word images are not carried into the body:
they become `![alt](TODO-image-N)` placeholders that the renderer leaves out, and the import
reports how many, because article images arrive through the shared Drive and are placed under
public/ by hand (the renderer only shows https:// and site-relative images).

`upsert_article()` is idempotent on slug: a second import of the same slug replaces the text
and keeps the row's status and published date, so re-running an import never publishes or
unpublishes anything. New rows are drafts unless the import says `--publish`.

Every public read filters `status = 'published'`; the drafts flag exists for the CLI only.
"""

from __future__ import annotations

import io
import re
import unicodedata
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path

from sqlalchemy import func, select
from sqlalchemy.orm import Session

from app.models.editorial import ARTICLE_KINDS, Article, ArticleOut, ArticleSummary
from app.services.markdown import plain_text, render_markdown

EXCERPT_CHARS = 200
DESCRIPTION_CHARS = 155
MAX_SLUG = 120

# Word styles beyond mammoth's defaults: a document's Title is the article title, its
# Subtitle reads as an ordinary paragraph (the standfirst is passed explicitly).
_STYLE_MAP = "\n".join(
    [
        "p[style-name='Title'] => h1:fresh",
        "p[style-name='Subtitle'] => p:fresh",
    ]
)

_H1 = re.compile(r"^#\s+(.+?)\s*#*\s*$")
_NON_SLUG = re.compile(r"[^a-z0-9]+")
_ESCAPED = re.compile(r"\\([\\`*_{}\[\]()#+\-.!>|~])")


@dataclass
class Document:
    """A hand-in read into its parts, before anything touches the database."""

    body_md: str
    title: str | None = None
    format: str = "md"  # md | txt | docx
    dropped_images: int = 0
    warnings: list[str] = field(default_factory=list)


def slugify(text: str) -> str:
    """ASCII, lowercase, hyphenated; accents folded; empty when nothing survives."""
    folded = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
    return _NON_SLUG.sub("-", folded.lower()).strip("-")[:MAX_SLUG].rstrip("-")


def article_path(slug: str) -> str:
    return f"/articles/{slug}"


def split_title(body_md: str) -> tuple[str | None, str]:
    """A `# Title` as the first non-blank line is the title; the rest is the body."""
    lines = body_md.replace("\r\n", "\n").split("\n")
    for k, line in enumerate(lines):
        if not line.strip():
            continue
        m = _H1.match(line)
        if m:
            title = _ESCAPED.sub(r"\1", m.group(1)).strip()
            return title, "\n".join(lines[k + 1 :]).lstrip("\n")
        return None, body_md
    return None, body_md


def read_document(data: bytes, name: str) -> Document:
    """Bytes plus the original file name (its suffix decides the reader)."""
    suffix = Path(name).suffix.lower()
    if suffix == ".docx" or data[:4] == b"PK\x03\x04":
        return _read_docx(data)
    text = data.decode("utf-8-sig", errors="replace")
    title, body = split_title(text)
    return Document(
        body_md=body.strip() + "\n", title=title, format="md" if suffix == ".md" else "txt"
    )


def _read_docx(data: bytes) -> Document:
    import mammoth  # imported here so a text import never needs it
    from mammoth import images

    dropped = 0

    def placeholder(image):
        nonlocal dropped
        dropped += 1
        return {
            "src": f"TODO-image-{dropped}",
            "alt": (getattr(image, "alt_text", None) or f"image {dropped}"),
        }

    result = mammoth.convert_to_markdown(
        io.BytesIO(data), style_map=_STYLE_MAP, convert_image=images.img_element(placeholder)
    )
    markdown = _unescape_mammoth(result.value.replace("\r\n", "\n"))
    markdown = re.sub(r"\n{3,}", "\n\n", markdown).strip() + "\n"
    title, body = split_title(markdown)
    warnings = [m.message for m in result.messages if "style with ID" not in m.message]
    return Document(
        body_md=body.strip() + "\n",
        title=title,
        format="docx",
        dropped_images=dropped,
        warnings=warnings,
    )


def _unescape_mammoth(markdown: str) -> str:
    """mammoth backslash-escapes every stop, hyphen, bracket and hash it writes. The renderer
    reads the escapes, but a person editing body_md later should not have to, so the ones
    that can never change meaning are dropped: anything not at the start of a line (where a
    hyphen, hash or `1.` would open a list or heading)."""
    out = []
    for line in markdown.split("\n"):
        # Keep whatever would open a block if unescaped: a leading `\-`, `\#`, `\+`, `\>`
        # or `1\.`; everything after that first token is plain text.
        m = re.match(r"^\s*(?:\\[-#+>]|\d{1,3}\\\.)", line)
        head, rest = (line[: m.end()], line[m.end() :]) if m else ("", line)
        rest = re.sub(r"\\([.\-(){}!#+>])", r"\1", rest)
        out.append(head + rest)
    return "\n".join(out)


@dataclass
class ImportResult:
    slug: str
    created: bool
    status: str
    title: str
    dropped_images: int = 0
    warnings: list[str] = field(default_factory=list)


def default_slug(
    kind: str, title: str | None, airport_code: str | None, category: str | None, fallback: str
) -> str:
    """One slug per airport or category for the keyed kinds, so a re-import replaces the
    earlier text instead of adding a second write-up; articles slug from their title."""
    if kind == "airport_writeup" and airport_code:
        return f"airport-{airport_code.lower()}"
    if kind == "category_intro" and category:
        return f"category-{slugify(category)}"
    return slugify(title or fallback) or slugify(fallback) or "untitled"


def upsert_article(
    db: Session,
    doc: Document,
    *,
    kind: str = "article",
    slug: str | None = None,
    title: str | None = None,
    standfirst: str | None = None,
    airport_code: str | None = None,
    category: str | None = None,
    author_id: int | None = None,
    hero_image: str | None = None,
    source_upload_id: int | None = None,
    publish: bool = False,
    fallback_name: str = "untitled",
    now: datetime | None = None,
) -> ImportResult:
    """Create or replace the article with this slug. Status is only ever moved by `publish`."""
    if kind not in ARTICLE_KINDS:
        raise ValueError(f"kind must be one of {', '.join(ARTICLE_KINDS)}")
    if kind == "airport_writeup" and not airport_code:
        raise ValueError("an airport write-up needs --airport <IATA>")
    if kind == "category_intro" and not category:
        raise ValueError("a category intro needs --category <name>")
    airport_code = airport_code.upper() if airport_code else None
    final_title = (title or doc.title or "").strip()
    if not final_title:
        final_title = _humanise(fallback_name)
    final_slug = (
        slugify(slug)
        if slug
        else default_slug(kind, final_title, airport_code, category, fallback_name)
    )
    if not final_slug:
        raise ValueError("could not derive a slug; pass --slug")
    now = now or datetime.now(UTC)

    row = db.scalar(select(Article).where(Article.slug == final_slug))
    created = row is None
    if row is None:
        row = Article(slug=final_slug, status="draft")
        db.add(row)
    row.title = final_title[:200]
    row.body_md = doc.body_md
    row.kind = kind
    row.category = category
    row.airport_code = airport_code
    if standfirst is not None:
        row.standfirst = standfirst.strip() or None
    if hero_image is not None:
        row.hero_image = hero_image.strip() or None
    if author_id is not None:
        row.author_id = author_id
    if source_upload_id is not None:
        row.source_upload_id = source_upload_id
    if publish:
        set_status(row, "published", now)
    db.commit()
    return ImportResult(
        slug=row.slug,
        created=created,
        status=row.status,
        title=row.title,
        dropped_images=doc.dropped_images,
        warnings=list(doc.warnings),
    )


def set_status(row: Article, status: str, now: datetime | None = None) -> None:
    """Publish or unpublish. The first publication date is kept across re-publishes."""
    if status not in ("draft", "published"):
        raise ValueError("status must be draft or published")
    row.status = status
    if status == "published" and row.published_at is None:
        row.published_at = now or datetime.now(UTC)


def _humanise(name: str) -> str:
    stem = Path(name).stem
    return re.sub(r"[-_]+", " ", stem).strip().capitalize() or "Untitled"


# --- reads ----------------------------------------------------------------------------------


def _published(stmt):
    return stmt.where(Article.status == "published")


def article_by_slug(db: Session, slug: str, *, published_only: bool = True) -> Article | None:
    stmt = select(Article).where(Article.slug == slug)
    if published_only:
        stmt = _published(stmt)
    return db.scalar(stmt)


def list_articles(
    db: Session,
    *,
    kind: str = "article",
    limit: int = 24,
    offset: int = 0,
    published_only: bool = True,
) -> tuple[int, list[Article]]:
    """Newest first by publication date, then by last edit."""
    base = select(Article).where(Article.kind == kind)
    if published_only:
        base = _published(base)
    total = db.scalar(select(func.count()).select_from(base.subquery())) or 0
    rows = db.scalars(
        base.order_by(
            Article.published_at.desc().nulls_last(), Article.updated_at.desc(), Article.id.desc()
        )
        .limit(limit)
        .offset(offset)
    ).all()
    return int(total), list(rows)


def airport_writeup(db: Session, iata: str) -> Article | None:
    """The published write-up for an airport, newest if there is somehow more than one."""
    return db.scalar(
        _published(
            select(Article).where(
                Article.kind == "airport_writeup", Article.airport_code == iata.upper()
            )
        )
        .order_by(Article.published_at.desc(), Article.id.desc())
        .limit(1)
    )


def category_intro(db: Session, category: str) -> Article | None:
    """The published intro paragraph for one of our category names."""
    return db.scalar(
        _published(
            select(Article).where(Article.kind == "category_intro", Article.category == category)
        )
        .order_by(Article.published_at.desc(), Article.id.desc())
        .limit(1)
    )


def sitemap_rows(db: Session) -> list[tuple[str, datetime | None]]:
    """(path, lastmod) for every published article with its own page, for Stream B's sitemap."""
    rows = db.execute(
        _published(
            select(Article.slug, Article.updated_at, Article.published_at).where(
                Article.kind == "article"
            )
        ).order_by(Article.published_at.desc())
    ).all()
    return [(article_path(slug), updated or published) for slug, updated, published in rows]


def feed_items(db: Session, limit: int = 20) -> list[ArticleOut]:
    """The newest published articles with rendered bodies, for Stream B's RSS."""
    _, rows = list_articles(db, kind="article", limit=limit)
    return [article_out(a) for a in rows]


# --- shapes ---------------------------------------------------------------------------------


#: Words a minute for the reading time on a card and a byline (a common blog figure).
READING_WPM = 220


def reading_minutes(body_md: str) -> int:
    """Minutes to read a body, rounded, never under one."""
    return max(1, round(len(plain_text(body_md).split()) / READING_WPM))


def article_summary(a: Article) -> ArticleSummary:
    return ArticleSummary(
        slug=a.slug,
        path=article_path(a.slug),
        title=a.title,
        standfirst=a.standfirst,
        excerpt=a.standfirst or plain_text(a.body_md, EXCERPT_CHARS),
        kind=a.kind,
        category=a.category,
        airport_code=a.airport_code,
        hero_image=a.hero_image,
        author=a.author.display_name if a.author is not None else None,
        reading_minutes=reading_minutes(a.body_md),
        published_at=a.published_at,
        updated_at=a.updated_at,
    )


def article_out(a: Article) -> ArticleOut:
    summary = article_summary(a)
    return ArticleOut(
        **summary.model_dump(),
        body_html=render_markdown(a.body_md),
        description=a.standfirst or plain_text(a.body_md, DESCRIPTION_CHARS),
    )
