"""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 argparse
import io
import pathlib
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 import cli_editorial
from sqlalchemy import select
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


# --- samples, tags and the hero credit (Stream AW5.2) -------------------------------------

SAMPLES = pathlib.Path(__file__).resolve().parents[2] / "import" / "articles" / "samples"


def _import_args(path, **over):
    """The namespace `articles import` builds, with every flag the command reads."""
    base = dict(
        file=str(path), upload=None, todo=None, kind="article", airport=None, category=None,
        slug=None, title=None, standfirst=None, author=None, hero_image=None, hero_credit=None,
        tag=None, sample=False, publish=False,
    )
    base.update(over)
    return argparse.Namespace(**base)


@pytest.fixture
def cli(db, monkeypatch):
    """`cli_editorial` run against the in-memory session the other tests use."""

    class Factory:
        def __enter__(self):
            return db

        def __exit__(self, *exc):
            return False

    monkeypatch.setattr(cli_editorial, "SessionLocal", Factory)
    return cli_editorial


class TestSamplesAndTags:
    """The launch plan rests on the `sample` column: ten pieces the Professor wrote sit
    published on staging so the client can see an article centre, and one command takes every
    one of them to draft before production's database is replaced. A list of ten slugs pasted
    into a checklist is the version of that step that gets half-run."""

    def test_the_sample_flag_is_whatever_the_command_last_said(self, cli, db, tmp_path):
        path = tmp_path / "a-piece.md"
        path.write_text("# A piece\n\nWords.\n")
        assert cli.cmd_articles_import(_import_args(path, sample=True)) == 0
        assert db.scalar(select(Article).where(Article.slug == "a-piece")).sample is True
        # A re-import that still says --sample keeps it: the flag is not lost to an edit pass.
        assert cli.cmd_articles_import(_import_args(path, sample=True)) == 0
        db.expire_all()
        assert db.scalar(select(Article).where(Article.slug == "a-piece")).sample is True
        # And a re-import without it un-flags the row, so no sample can hide from --samples
        # because somebody once imported it plainly.
        assert cli.cmd_articles_import(_import_args(path)) == 0
        db.expire_all()
        assert db.scalar(select(Article).where(Article.slug == "a-piece")).sample is False

    def test_unpublish_samples_takes_every_sample_and_nothing_else(self, cli, db, tmp_path):
        for name, sample in (("sample-one", True), ("sample-two", True), ("real-piece", False)):
            path = tmp_path / f"{name}.md"
            path.write_text(f"# {name}\n\nWords.\n")
            assert cli.cmd_articles_import(_import_args(path, sample=sample, publish=True)) == 0
        db.expire_all()
        assert cli.cmd_articles_unpublish(argparse.Namespace(slug=[], samples=True)) == 0
        db.expire_all()
        rows = {a.slug: a.status for a in db.scalars(select(Article))}
        assert rows == {"sample-one": "draft", "sample-two": "draft", "real-piece": "published"}
        # The published date survives the round trip, so re-publishing is not a new piece.
        assert db.scalar(select(Article).where(Article.slug == "sample-one")).published_at

    def test_unpublish_needs_slugs_or_samples_and_never_both(self, cli, db):
        with pytest.raises(SystemExit):
            cli.cmd_articles_unpublish(argparse.Namespace(slug=[], samples=False))
        with pytest.raises(SystemExit):
            cli.cmd_articles_unpublish(argparse.Namespace(slug=["x"], samples=True))

    def test_tags_are_lowercased_deduplicated_and_capped(self, cli, db, tmp_path):
        path = tmp_path / "tagged.md"
        path.write_text("# Tagged\n\nWords.\n")
        raw = ["Whisky", "whisky", " Buying  Guides ", "!!bad!!", "", *[f"t{i}" for i in range(9)]]
        assert cli.cmd_articles_import(_import_args(path, tag=raw)) == 0
        row = db.scalar(select(Article).where(Article.slug == "tagged"))
        assert row.tags == ["whisky", "buying guides", "t0", "t1", "t2", "t3", "t4", "t5"]
        assert len(row.tags) == editorial.MAX_TAGS
        # No --tag at all leaves what a person put there; `--tag ""` is how you clear it.
        assert cli.cmd_articles_import(_import_args(path)) == 0
        db.expire_all()
        assert db.scalar(select(Article).where(Article.slug == "tagged")).tags[0] == "whisky"
        assert cli.cmd_articles_import(_import_args(path, tag=[""])) == 0
        db.expire_all()
        assert db.scalar(select(Article).where(Article.slug == "tagged")).tags == []

    def test_a_public_read_carries_the_mark_the_tags_and_the_credit(self, cli, client, db, tmp_path):
        path = tmp_path / "shown.md"
        path.write_text("# Shown\n\nWords.\n")
        assert cli.cmd_articles_import(_import_args(
            path, sample=True, publish=True, tag=["whisky"],
            hero_image="/uploads/images/line/x-1234abcd.webp", hero_credit="Supplied by the client",
        )) == 0
        db.expire_all()
        item = client.get("/api/articles").json()["items"][0]
        assert item["sample"] is True and item["tags"] == ["whisky"]
        assert item["hero_credit"] == "Supplied by the client"
        one = client.get("/api/articles/shown").json()
        assert one["sample"] is True and one["hero_credit"] == "Supplied by the client"

    def test_list_samples_prints_the_samples_including_drafts(self, cli, db, tmp_path, capsys):
        for name, sample, publish in (("s", True, False), ("r", False, True)):
            path = tmp_path / f"{name}.md"
            path.write_text(f"# {name}\n\nWords.\n")
            assert cli.cmd_articles_import(_import_args(path, sample=sample, publish=publish)) == 0
        capsys.readouterr()
        assert cli.cmd_articles_list(argparse.Namespace(all=False, samples=True)) == 0
        out = capsys.readouterr().out
        assert " s " in f" {out} " and "sample" in out and '"r"' not in out


class TestTheTagFilterAndTheCentresFields:
    """`GET /api/articles?tag=` and the three fields the centre and the piece link from
    (AW5.3). The filter matches the quoted token inside the JSON array, which is exact on both
    databases; matching the bare word would have made `?tag=gin` answer with "gin guide" too."""

    def _write(self, cli, tmp_path, slug, **over):
        path = tmp_path / f"{slug}.md"
        path.write_text(f"# {slug}\n\nWords.\n")
        assert cli.cmd_articles_import(_import_args(path, publish=True, **over)) == 0

    def test_a_tag_matches_a_whole_label_and_never_a_word_inside_one(self, cli, client, db, tmp_path):
        self._write(cli, tmp_path, "one", tag=["gin"])
        self._write(cli, tmp_path, "two", tag=["gin guide"])
        self._write(cli, tmp_path, "three", tag=["whisky", "gin"])
        db.expire_all()
        got = client.get("/api/articles?tag=gin").json()
        assert {i["slug"] for i in got["items"]} == {"one", "three"} and got["total"] == 2
        assert {i["slug"] for i in client.get("/api/articles?tag=gin guide").json()["items"]} == {"two"}
        assert client.get("/api/articles?tag=whisky").json()["total"] == 1

    def test_the_page_carries_the_whole_vocabulary_and_the_tag_it_is_filtered_to(self, cli, client, db, tmp_path):
        self._write(cli, tmp_path, "one", tag=["gin"])
        self._write(cli, tmp_path, "two", tag=["whisky", "gin"])
        self._write(cli, tmp_path, "draft-piece", tag=["nobody-sees-this"])
        db.scalar(select(Article).where(Article.slug == "draft-piece")).status = "draft"
        db.commit()
        got = client.get("/api/articles?tag=gin").json()
        # Every tag in use, not just this page's, so the filter rail is whole however deep
        # the reader is; a draft's tags are nobody's business.
        assert got["tags"] == ["gin", "whisky"] and got["tag"] == "gin"

    def test_a_tag_of_no_tag_shape_is_ignored_like_every_other_mangled_filter(self, cli, client, db, tmp_path):
        self._write(cli, tmp_path, "one", tag=["gin"])
        db.expire_all()
        assert client.get("/api/articles?tag=" + "x" * 40).json()["total"] == 0  # a real tag, on no row
        assert client.get("/api/articles?tag=!!").json()["total"] == 1  # no tag at all: the whole room
        assert client.get("/api/articles?tag=GIN").json()["total"] == 1  # cleaned as an import cleans it

    def test_the_kicker_carries_its_category_page_only_when_it_is_a_category(self, cli, client, db, tmp_path):
        self._write(cli, tmp_path, "about-whisky", category="Whisky")
        self._write(cli, tmp_path, "a-guide", category="Buying guides")
        db.expire_all()
        paths = {i["slug"]: i["category_path"] for i in client.get("/api/articles").json()["items"]}
        assert paths == {"about-whisky": "/alcohol/whisky", "a-guide": None}

    def test_a_piece_about_a_brand_names_its_page(self, cli, client, db, tmp_path):
        """`brand_slug` is what the piece's related block links; a piece about no brand asks
        the brands table nothing at all (a many-to-one over a null key never queries)."""
        from app.models import Brand

        Brand.__table__.create(db.get_bind())
        db.add(Brand(slug="macallan", name="Macallan"))
        db.commit()
        self._write(cli, tmp_path, "plain")
        self._write(cli, tmp_path, "about-a-brand")
        row = db.scalar(select(Article).where(Article.slug == "about-a-brand"))
        row.brand_id = db.scalar(select(Brand.id))
        db.commit()
        got = {i["slug"]: (i["brand_slug"], i["brand_name"]) for i in client.get("/api/articles").json()["items"]}
        assert got == {"about-a-brand": ("macallan", "Macallan"), "plain": (None, None)}


class TestTheIdSequence:
    """The real row that failed. On 19 Sep staging held one article at id 1 while
    `articles_id_seq` had never been called, so `nextval` would hand back 1 and the first
    `articles import` of the ten samples died on `duplicate key value violates unique
    constraint "articles_pkey"` before a word was written. A staging refresh replays the rows
    people wrote by natural key, ids included, and leaves the sequence where it stood. Cost:
    it would have taken the launch-night import down at the first command, in the one window
    nobody is watching."""

    @pytest.mark.parametrize("last_value, start, step, want", [
        (None, 1, 1, 1),      # never called: the next id is the start, which may be taken
        (None, 5, 1, 5),
        (1, 1, 1, 2),         # called once: the next id is one past it
        (2448, 1, 1, 2449),
        (10, 1, 5, 15),
    ])
    def test_what_nextval_will_hand_out(self, last_value, start, step, want):
        assert cli_editorial.next_sequence_value(last_value, start, step) == want

    def test_the_lift_is_a_no_op_off_postgres_and_the_import_still_runs(self, cli, db, tmp_path):
        """The suite runs on SQLite, which has no such sequence: the guard must read the
        dialect and return, never raise, or every import in every test would fail here."""
        cli_editorial._lift_article_sequence(db)
        path = tmp_path / "after-the-guard.md"
        path.write_text("# After the guard\n\nWords.\n")
        assert cli.cmd_articles_import(_import_args(path)) == 0
        assert db.scalar(select(Article).where(Article.slug == "after-the-guard"))


class TestTheSampleFolder:
    """The ten pieces and the README that imports them. The publish line names slugs, and a
    slug is folded from the title, so a title edited without renaming the file would leave the
    README naming a slug that does not exist. That is a silent failure at launch, so it is
    pinned here instead."""

    def test_there_are_ten_pieces(self):
        assert len(sorted(p for p in SAMPLES.glob("*.md") if p.name != "README.md")) == 10

    def test_every_file_name_is_the_slug_its_title_folds_to(self):
        for path in sorted(SAMPLES.glob("*.md")):
            if path.name == "README.md":
                continue
            title, body = split_title(path.read_text())
            assert title, f"{path.name} has no `# Title` first line"
            assert slugify(title) == path.stem, f"{path.name}: title folds to {slugify(title)!r}"
            assert body.strip(), f"{path.name} has a title and no body"

    def test_the_readme_imports_every_piece_with_the_sample_flag(self):
        readme = (SAMPLES / "README.md").read_text()
        for path in sorted(SAMPLES.glob("*.md")):
            if path.name == "README.md":
                continue
            line = next(
                (ln for ln in readme.splitlines() if f"samples/{path.name}" in ln), None
            )
            assert line, f"{path.name} has no import command in README.md"
            assert "--sample" in line, f"{path.name} is imported without --sample"
            assert "--standfirst" in line and "--category" in line and "--tag" in line
            assert path.stem in readme.split("articles publish")[1], f"{path.stem} is not published"
