"""Articles: reading a hand-in, the idempotent import, and the published-only reads.

The database parts run on an in-memory SQLite with only the editorial tables created; that
is not "the database" (the tests README) but it is the only way to prove the rules that
matter here without a Postgres: a re-import never changes status, a draft is invisible to
every public read, and the routes answer what the SPA and Stream B's pages expect.
"""

import io
import zipfile
from datetime import UTC, datetime

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool

from app.db import get_db
from app.models.editorial import Article, Subscriber
from app.routers import articles as articles_router
from app.services import editorial
from app.services.editorial import (
    Document,
    default_slug,
    read_document,
    slugify,
    split_title,
    upsert_article,
)


@pytest.fixture
def db():
    engine = create_engine(
        "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
    )
    Article.__table__.create(engine)
    Subscriber.__table__.create(engine)
    factory = sessionmaker(bind=engine, expire_on_commit=False)
    session: Session = factory()
    yield session
    session.close()


@pytest.fixture
def client(db):
    app = FastAPI()
    app.include_router(articles_router.router)

    def override():
        yield db

    app.dependency_overrides[get_db] = override
    return TestClient(app)


# --- a Word file, built in memory --------------------------------------------------------------

_CT = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
    '<Default Extension="xml" ContentType="application/xml"/>'
    '<Default Extension="png" ContentType="image/png"/>'
    '<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>'
    '<Override PartName="/word/numbering.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml"/>'
    "</Types>"
)
_RELS = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>'
    "</Relationships>"
)
_DOC_RELS = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
    '<Relationship Id="rId7" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.png"/>'
    '<Relationship Id="rId8" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering" Target="numbering.xml"/>'
    "</Relationships>"
)
_NUMBERING = (
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
    '<w:numbering xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">'
    '<w:abstractNum w:abstractNumId="0"><w:lvl w:ilvl="0"><w:numFmt w:val="bullet"/></w:lvl></w:abstractNum>'
    '<w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num>'
    "</w:numbering>"
)
_W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_DOC = f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="{_W}" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
 xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"
 xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>
<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>Heathrow: what to buy, what to skip</w:t></w:r></w:p>
<w:p><w:r><w:t xml:space="preserve">Terminal 5 has the widest range. </w:t></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>Litre bottles</w:t></w:r><w:r><w:t xml:space="preserve"> are the value play - duty-free only. Price &lt; value.</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading2"/></w:pPr><w:r><w:t>Whisky</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>Talisker 10 at 1.0L</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>Glenfiddich 15</w:t></w:r></w:p>
<w:p><w:r><w:drawing><wp:inline><wp:docPr id="1" name="Picture 1" descr="Terminal 5 shop"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rId7"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>
<w:p><w:r><w:t>Closing line.</w:t></w:r></w:p>
</w:body></w:document>"""
_PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 16


def make_docx() -> bytes:
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as zf:
        zf.writestr("[Content_Types].xml", _CT)
        zf.writestr("_rels/.rels", _RELS)
        zf.writestr("word/_rels/document.xml.rels", _DOC_RELS)
        zf.writestr("word/numbering.xml", _NUMBERING)
        zf.writestr("word/document.xml", _DOC)
        zf.writestr("word/media/image1.png", _PNG)
    return buf.getvalue()


class TestReading:
    def test_markdown_title_is_lifted_out_of_the_body(self):
        doc = read_document(
            b"\n# The whisky worth carrying home\n\nFirst para.\n\n## Section\n", "piece.md"
        )
        assert doc.title == "The whisky worth carrying home"
        assert doc.body_md == "First para.\n\n## Section\n"
        assert doc.format == "md"

    def test_plain_text_without_a_heading_has_no_title(self):
        doc = read_document("Just words.\n\nMore words.".encode(), "notes.txt")
        assert (
            doc.title is None
            and doc.body_md == "Just words.\n\nMore words.\n"
            and doc.format == "txt"
        )

    def test_utf8_bom_and_crlf(self):
        doc = read_document(b"\xef\xbb\xbf# T\r\n\r\nBody\r\n", "x.md")
        assert doc.title == "T" and doc.body_md == "Body\n"

    def test_word_file_becomes_markdown_with_the_title_lifted_and_images_counted(self):
        doc = read_document(make_docx(), "heathrow.docx")
        assert doc.format == "docx"
        assert doc.title == "Heathrow: what to buy, what to skip"
        body = doc.body_md
        assert "# Heathrow" not in body
        assert "__Litre bottles__" in body
        assert "duty-free only. Price < value." in body  # mammoth's escapes dropped where harmless
        assert "## Whisky" in body
        assert "- Talisker 10 at 1.0L" in body and "- Glenfiddich 15" in body
        assert doc.dropped_images == 1 and "![Terminal 5 shop](TODO-image-1)" in body
        html = editorial.render_markdown(body)
        assert "<img" not in html and "<strong>Litre bottles</strong>" in html
        assert "<ul><li>Talisker 10 at 1.0L</li><li>Glenfiddich 15</li></ul>" in html

    def test_docx_is_recognised_by_bytes_when_the_name_lies(self):
        assert read_document(make_docx(), "article.bin").format == "docx"

    def test_split_title_unescapes_mammoth(self):
        assert split_title("# Heathrow\\: T5\\. Notes\n\nx")[0] == "Heathrow\\: T5. Notes"


class TestSlugs:
    def test_slugify(self):
        assert slugify("  The Whisky Worth Carrying Home!  ") == "the-whisky-worth-carrying-home"
        assert slugify("Crème brûlée & Château") == "creme-brulee-chateau"
        assert slugify("!!!") == ""
        assert len(slugify("x" * 500)) == 120

    def test_keyed_kinds_get_one_slug_per_key(self):
        assert default_slug("airport_writeup", "Anything", "lhr", None, "f.docx") == "airport-lhr"
        assert (
            default_slug("category_intro", "Anything", None, "Single Malt Whisky", "f.docx")
            == "category-single-malt-whisky"
        )
        assert default_slug("article", "My Title", None, None, "f.docx") == "my-title"
        assert (
            default_slug("article", None, None, None, "Heathrow guide.docx")
            == "heathrow-guide-docx"
        )


class TestUpsert:
    def test_new_import_is_a_draft_and_invisible_to_public_reads(self, db):
        r = upsert_article(db, Document(body_md="Body.\n", title="A Piece"), fallback_name="a.md")
        assert r.created and r.status == "draft" and r.slug == "a-piece"
        assert editorial.article_by_slug(db, "a-piece") is None
        assert editorial.article_by_slug(db, "a-piece", published_only=False) is not None
        assert editorial.list_articles(db) == (0, [])

    def test_reimport_replaces_text_and_keeps_status_and_publication_date(self, db):
        then = datetime(2026, 9, 1, tzinfo=UTC)
        upsert_article(
            db,
            Document(body_md="v1\n", title="A Piece"),
            publish=True,
            now=then,
            standfirst="First",
        )
        r = upsert_article(
            db, Document(body_md="v2\n", title="A Piece"), now=datetime(2026, 9, 9, tzinfo=UTC)
        )
        assert not r.created and r.status == "published"
        row = editorial.article_by_slug(db, "a-piece")
        assert row.body_md == "v2\n" and row.published_at.replace(tzinfo=UTC) == then
        assert row.standfirst == "First"  # not passed the second time: kept
        assert db.query(Article).count() == 1

    def test_publish_and_unpublish_keep_the_first_date(self, db):
        upsert_article(db, Document(body_md="x\n", title="P"))
        row = editorial.article_by_slug(db, "p", published_only=False)
        editorial.set_status(row, "published", datetime(2026, 9, 2, tzinfo=UTC))
        editorial.set_status(row, "draft")
        editorial.set_status(row, "published", datetime(2026, 9, 5, tzinfo=UTC))
        assert row.published_at.replace(tzinfo=UTC) == datetime(2026, 9, 2, tzinfo=UTC)

    def test_keyed_kinds_need_their_key_and_upper_case_the_airport(self, db):
        with pytest.raises(ValueError):
            upsert_article(db, Document(body_md="x\n"), kind="airport_writeup")
        with pytest.raises(ValueError):
            upsert_article(db, Document(body_md="x\n"), kind="category_intro")
        with pytest.raises(ValueError):
            upsert_article(db, Document(body_md="x\n"), kind="essay")
        r = upsert_article(
            db,
            Document(body_md="x\n"),
            kind="airport_writeup",
            airport_code="lhr",
            fallback_name="Heathrow.docx",
            publish=True,
        )
        assert r.slug == "airport-lhr" and r.title == "Heathrow"
        assert editorial.airport_writeup(db, "lhr").airport_code == "LHR"
        assert editorial.airport_writeup(db, "JFK") is None

    def test_explicit_slug_and_title_win(self, db):
        r = upsert_article(
            db, Document(body_md="x\n", title="Doc Title"), slug="Custom Slug!", title="Given"
        )
        assert r.slug == "custom-slug" and r.title == "Given"


class TestReads:
    def _seed(self, db):
        upsert_article(
            db,
            Document(body_md="Old body words.\n", title="Old"),
            publish=True,
            now=datetime(2026, 8, 1, tzinfo=UTC),
        )
        upsert_article(
            db,
            Document(body_md="New body words.\n", title="New"),
            publish=True,
            now=datetime(2026, 9, 1, tzinfo=UTC),
            standfirst="Fresh.",
        )
        upsert_article(db, Document(body_md="Draft words.\n", title="Draft"))
        upsert_article(
            db,
            Document(body_md="Whisky intro.\n"),
            kind="category_intro",
            category="Whisky",
            publish=True,
        )
        upsert_article(
            db,
            Document(body_md="JFK words.\n"),
            kind="airport_writeup",
            airport_code="JFK",
            publish=True,
        )

    def test_list_is_newest_first_published_articles_only(self, db):
        self._seed(db)
        total, rows = editorial.list_articles(db)
        assert total == 2 and [a.slug for a in rows] == ["new", "old"]
        assert [p for p, _ in editorial.sitemap_rows(db)] == ["/articles/new", "/articles/old"]
        assert [f.slug for f in editorial.feed_items(db)] == ["new", "old"]

    def test_shapes(self, db):
        self._seed(db)
        out = editorial.article_out(editorial.article_by_slug(db, "old"))
        assert out.path == "/articles/old" and out.body_html == "<p>Old body words.</p>"
        assert out.excerpt == "Old body words." and out.description == "Old body words."
        fresh = editorial.article_summary(editorial.article_by_slug(db, "new"))
        assert fresh.excerpt == "Fresh." and fresh.standfirst == "Fresh."

    def test_routes(self, client, db):
        self._seed(db)
        page = client.get("/api/articles").json()
        assert page["total"] == 2 and [i["slug"] for i in page["items"]] == ["new", "old"]
        assert "body_html" not in page["items"][0]
        assert client.get("/api/articles/new").json()["body_html"] == "<p>New body words.</p>"
        assert client.get("/api/articles/draft").status_code == 404
        assert client.get("/api/articles/never").status_code == 404
        assert (
            client.get("/api/articles/category/Whisky").json()["body_html"]
            == "<p>Whisky intro.</p>"
        )
        assert client.get("/api/articles/category/Gin").json() is None
        assert client.get("/api/articles/airport/jfk").json()["airport_code"] == "JFK"
        assert client.get("/api/articles/airport/LHR").json() is None
        assert client.get("/api/articles/airport/LHRX").status_code == 404
        assert client.get("/api/articles?kind=category_intro").json()["total"] == 1
        assert client.get("/api/articles?kind=essay").status_code == 422


class TestByline:
    """Every card and article prints "date · author" (the Professor sites' meta
    line) and the page adds a reading time; the API sends both."""

    def test_reading_time_is_words_over_the_blog_rate_and_never_zero(self):
        from app.services.editorial import READING_WPM, reading_minutes

        assert reading_minutes("") == 1
        assert reading_minutes("word " * 30) == 1
        assert reading_minutes("word " * (READING_WPM * 4)) == 4
        # Markup does not count as words.
        assert reading_minutes("## Heading\\n\\n**bold** [a link](/x)") == 1
