"""The picture manifest (Stream AW3.1): names classified, targets matched precision first, one
picture per target or none with the reason.

Pinned on the real names in the client's folder of 19 Sep 2026 and the Pernod Ricard USA export:
a `_BACK` shot is unusable; `BOTTLE_AND_BOX_TRANSPARENT` serves the line at box quality; the
UPC in `Glenfiddich_18YO_083664990412_Front_750ml-2.jpg` is read and its `-2` is a version, not
an age; a case GTIN-14 (leading 1) is dropped; two candidates of equal quality leave the target
empty. What the wrong answer costs: a picture of the 18 on the 12's page, or a brand logo where
a product's structured data says `image`, is a wrong fact published under the client's name.
"""

from __future__ import annotations

import csv
import json

import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool

from app.models import Base, Brand, ProductLine, ProductVariant
from app.services import image_manifest as im
from app.services.keying import load_maps

TABLES = [Brand.__table__, ProductLine.__table__, ProductVariant.__table__]
WGS = "Duty Free Professor Imagery-20260919T053019Z-1-001/Duty Free Professor Imagery"
UPLOAD = "Duty Free Bottle Images To Upload-20260919T053017Z-1-001/Duty Free Bottle Images To Upload"


@pytest.fixture
def engine():
    engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
    Base.metadata.create_all(engine, tables=TABLES)
    return engine


@pytest.fixture
def db(engine):
    factory = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True)
    with factory() as session:
        _seed(session)
        yield session


def _line(db, id, brand_id, key, name):
    row = ProductLine(id=id, brand_id=brand_id, key=key, name=name, slug=f"line-{id}")
    db.add(row)
    return row


def _variant(db, id, line, name, ml, gtin=None):
    row = ProductVariant(id=id, name=name, brand=line.brand_row.name, brand_id=line.brand_id, product_line_id=line.id,
                         quantity_ml=ml, gtin=gtin, match_key=f"k{id}", attributes={})
    db.add(row)
    return row


def _seed(db):
    g = Brand(id=1, slug="glenfiddich", name="Glenfiddich")
    t = Brand(id=2, slug="tullamore-dew", name="Tullamore Dew")
    b = Brand(id=3, slug="balvenie", name="The Balvenie")
    db.add_all([g, t, b])
    db.flush()
    lines = {
        11: _line(db, 11, 1, "15yo sherry cask", "15yo Sherry Cask"),
        12: _line(db, 12, 1, "15yo solera highland single malt", "15 Year Old Solera Highland Single Malt"),
        13: _line(db, 13, 1, "21yo single malt scotch whisky", "21YO Single Malt Scotch Whisky"),
        14: _line(db, 14, 1, "grande couronne 26yo", "Grande Couronne 26 Years Old"),
        15: _line(db, 15, 1, "18yo empty", "18 Year Old (no live variant)"),
        21: _line(db, 21, 2, "14yo", "14 year old"),
        22: _line(db, 22, 2, "", "Tullamore Dew"),
        31: _line(db, 31, 3, "12yo doublewood single malt", "12 Year Old DoubleWood Single Malt"),
        32: _line(db, 32, 3, "12yo golden cask", "12yo Golden Cask"),
        33: _line(db, 33, 3, "12yo doublewood scotch whisky", "12yo DoubleWood Scotch Whisky"),
    }
    db.flush()
    for ln in lines.values():
        ln.brand_row = {1: g, 2: t, 3: b}[ln.brand_id]
    _variant(db, 111, lines[11], "Glenfiddich 15 Sherry Cask 1L", 1000)
    _variant(db, 121, lines[12], "Glenfiddich 15 Solera 1L", 1000)
    _variant(db, 131, lines[13], "Glenfiddich 21 700ml", 700)
    _variant(db, 141, lines[14], "Glenfiddich Grande Couronne 700ml", 700)
    _variant(db, 201, lines[21], "Tullamore Dew 14 700ml", 700)
    _variant(db, 202, lines[21], "Tullamore Dew 14 1L", 1000)
    _variant(db, 221, lines[22], "Tullamore Dew 1L", 1000)
    _variant(db, 311, lines[31], "Balvenie DoubleWood 12 750ml", 750, gtin="0083664990412")
    _variant(db, 321, lines[32], "Balvenie Golden Cask 12 1L", 1000)
    _variant(db, 331, lines[33], "Balvenie DoubleWood 12 700ml", 700)
    db.commit()


class TestClassifyFile:
    def test_a_back_view_is_unusable(self):
        c = im.classify_file(f"{WGS}/Glenfiddich 15 Year Old/GLENFIDDICH_15YO_BOTTLE_SHOT_750ML_BACK.png")
        assert not c.usable and "back" in c.reason
        assert c.supplier == "william-grant-via-adam" and c.image_source == "admin:william-grant-via-adam"

    def test_bottle_and_box_transparent_serves_the_line_at_box_quality(self):
        c = im.classify_file(f"{WGS}/Glenfiddich 21 Year Old/GLENFIDDICH_750ML_21YO_BOTTLE_AND_BOX_TRANSPARENT.png")
        assert c.usable and c.line_ok and c.quality == "boxed" and c.quantity_ml == 750
        assert c.line_words == ("Glenfiddich 21 Year Old", "glenfiddich 21yo")

    def test_the_upc_in_the_name_is_read_and_the_trailing_2_is_a_version(self):
        c = im.classify_file(f"{WGS}/Glenfiddich 18 Year Old/Glenfiddich_18YO_083664990412_Front_750ml-2.jpg")
        assert c.gtin == "0083664990412" and c.quantity_ml == 750 and c.quality == "ecommerce"
        assert c.line_words[1] == "glenfiddich 18yo"

    def test_a_case_gtin_14_in_a_name_is_dropped_and_a_leading_zero_one_is_kept(self):
        assert im.gtins_of("X_10080432400583_Front") == ()
        assert im.gtins_of("X_00089540461878_Front") == ("0089540461878",)
        assert im.gtins_of("Copy of 5010327375335_Glenfiddich_Grand Yozakura_Bottle_FOP (1)") == ("5010327375335",)

    def test_packaging_alone_is_unusable_and_with_the_bottle_is_boxed(self):
        tube = im.classify_file(f"{WGS}/Glenfiddich 15 Year Old/GLENFIDDICH_15YO_BOTTLE_TUBE_750ML_FRONT.jpg")
        assert not tube.usable and "tube alone" in tube.reason
        box = im.classify_file(f"{WGS}/The Balvenie DoubleWood 12 Year Old/BALVENIE_750ML_12YO_BOX_TRANSPARENT.png")
        assert not box.usable and "box alone" in box.reason
        carton = im.classify_file(f"{WGS}/Glenfiddich Grand Yozakura/Copy of 5010327375335_Glenfiddich_Grand Yozakura_Carton_OPEN WITH BOTTLE (1).jpg")
        assert carton.usable and carton.quality == "boxed"
        pair = im.classify_file(f"{WGS}/The Balvenie 14 Year Old/Caribbean Cask 20cl bottle&Tube_20250623.png")
        assert pair.usable and pair.quality == "boxed" and pair.quantity_ml == 200

    def test_the_other_unusable_views(self):
        for name in ("GLENFIDDICH_15YO_BOTTLE_TUBE_750ML_BWC.png", "GLENFIDDICH_15YO_BOTTLE_SHOT_750ML_FRONT_CLOSE_UP.jpg"):
            assert not im.classify_file(f"{WGS}/Glenfiddich 15 Year Old/{name}").usable
        for name in ("14 - LAY BACK SIDE ICONS.png", "14 - LAY BACK.png"):
            assert not im.classify_file(f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/{name}").usable
        assert not im.classify_file(f"{WGS}/Glenfiddich Grand Yozakura/Copy of 5010327375335_Glenfiddich_Grand Yozakura_GROUP With Floor.jpg").usable

    def test_transparent_and_noshadow_rank_highest_and_a_bare_name_is_a_front_shot(self):
        assert im.classify_file(f"{WGS}/Glenfiddich 21 Year Old/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png").quality == "transparent"
        assert im.classify_file(f"{WGS}/The Balvenie 14 Year Old/The Balvenie CC 14YO_3000x3000_NoShadow.png").quality == "transparent"
        bare = im.classify_file(f"{UPLOAD}/Bruichladdich_21YO.jpg")
        assert bare.usable and bare.quality == "ecommerce" and bare.line_words == ("bruichladdich 21yo",)
        assert bare.brand_words == "bruichladdich 21yo" and bare.supplier == "bruichladdich-via-adam"

    def test_a_name_that_says_nothing_is_unusable(self):
        c = im.classify_file(f"{WGS}/Hendrick’s Original (1L Travel Format)/201c6400-b810-49bf-aeb0-5a469614e564.jpg")
        assert not c.usable and "says nothing" in c.reason
        assert not im.classify_file(f"{UPLOAD}/Pernod Ricard USA _2026-09-19T02_50_50Z.zip").usable

    def test_versions_are_dropped_but_a_leading_age_and_an_age_before_the_size_stay(self):
        c = im.classify_file(f"{WGS}/Tullamore D.E.W. Original/TULLAMORE_DEW_BOTTLE_SHOT_ORIGINAL_750ML_FRONT_04.png")
        assert c.line_words[1] == "tullamore dew original"
        c = im.classify_file(f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/14 - FRONT ON SHOT.png")
        assert c.line_words == ("Tullamore D.E.W. 14 Year Old Single Malt", "14") and c.quantity_ml is None
        c = im.classify_file(f"{UPLOAD}/Port Charlotte_10_1L.jpeg")
        assert c.line_words == ("port charlotte 10",) and c.quantity_ml == 1000
        c = im.classify_file(f"{WGS}/The Balvenie 14 Year Old/BALVENIE_750ML_14_YEAR_BOTTLE_TRANSPARENT (2) (6).png")
        assert c.line_words[1] == "balvenie 14 year"  # the key folds it to 14yo at match time

    def test_a_hand_a_sash_or_a_wrap_makes_it_variant_only(self):
        c = im.classify_file(f"{WGS}/Some Line/BRAND_750ML_BOTTLE_FRONT_WITH_SASH.png")
        assert c.usable and not c.line_ok


class TestClassifyCsvRow:
    ROW = {
        im.CSV_BRAND: "The Glenlivet", im.CSV_TITLE: "The Glenlivet 12 Year Old Single Malt Scotch Whisky, 50 ml Bottle, 80 Proof",
        im.CSV_UPC: "080432402535", im.CSV_GTIN: "10080432400583", im.CSV_NET: "50 ml",
        im.CSV_FRONT: "https://images.example/front.jpg", im.CSV_LOGO: "https://images.example/logo.png",
    }

    def test_the_upc_pads_to_13_and_the_case_gtin_is_dropped(self):
        front, logo = im.classify_csv_row(self.ROW)
        assert front.level == "variant" and front.gtin == "0080432402535" and front.alt_gtins == ()
        assert front.quantity_ml == 50 and front.line_words == ("The Glenlivet 12 Year Old Single Malt Scotch Whisky",)
        assert front.quality == "ecommerce" and front.image_source == "admin:pernod-ricard-via-adam"
        assert logo.level == "brand" and logo.source_url.endswith("logo.png") and logo.brand_words == "The Glenlivet"

    def test_a_gtin_14_with_a_leading_zero_is_a_second_barcode(self):
        row = dict(self.ROW, **{im.CSV_GTIN: "00089540461878", im.CSV_UPC: "089540122694", im.CSV_NET: "1.75 L"})
        front, _ = im.classify_csv_row(row)
        assert (front.gtin, front.alt_gtins, front.quantity_ml) == ("0089540122694", ("0089540461878",), 1750)

    def test_ounces_are_no_size_and_no_brand_is_unusable(self):
        front, _ = im.classify_csv_row(dict(self.ROW, **{im.CSV_NET: "12 oz"}))
        assert front.quantity_ml is None
        front, logo = im.classify_csv_row(dict(self.ROW, **{im.CSV_BRAND: ""}))
        assert not front.usable and not logo.usable


class TestMatch:
    def test_a_barcode_equal_to_a_variants_wins_over_the_folder(self, db):
        c = im.classify_file(f"{WGS}/Tullamore D.E.W. Original/Balvenie_083664990412_Front_750ml.jpg")
        m = im.match(db, c)
        assert m.variant.id == 311 and m.line.id == 31 and m.brand.slug == "balvenie" and m.by_gtin == "0083664990412"

    def test_a_fuller_name_meets_a_terser_key_when_only_category_words_differ(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/14 - FRONT ON SHOT.png"))
        assert m.brand.slug == "tullamore-dew" and m.line.id == 21 and m.variant is None
        assert "no size" in m.reason

    def test_the_size_picks_the_variant_under_the_line(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/TULLAMORE_14YO_700ML_FRONT.png"))
        assert m.line.id == 21 and m.variant.id == 201
        m = im.match(db, im.classify_file(f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/TULLAMORE_14YO_500ML_FRONT.png"))
        assert m.line.id == 21 and m.variant is None and "500 ml" in m.reason

    def test_an_expression_word_on_the_shop_side_refuses_the_bare_age(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/Glenfiddich 15 Year Old/GLENFIDDICH_15YO_BOTTLE_SHOT_750ML_FRONT.png"))
        assert m.brand.slug == "glenfiddich" and m.line is None and m.reason.startswith("no product line")

    def test_an_age_on_the_shop_side_refuses_a_name_without_one(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/Glenfiddich Grand Couronne/GLENFIDDICH_750ML_GRANDE_COURONNE_BOTTLE_TRANSPARENT.png"))
        assert m.line is None and "no product line" in m.reason

    def test_two_lines_that_read_the_same_are_a_stop_and_an_exact_key_settles_it(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/The Balvenie DoubleWood 12 Year Old/BALVENIE_750ML_12_YEAR_BOTTLE_TRANSPARENT.png"))
        assert m.line is None and m.reason.startswith("2 product lines under The Balvenie")
        m = im.match(db, im.classify_file(f"{WGS}/The Balvenie 12 Year Old DoubleWood Single Malt/BALVENIE_750ML_12_YEAR_BOTTLE_TRANSPARENT.png"))
        assert m.line.id == 31 and m.variant.id == 311

    def test_a_line_without_a_live_variant_never_matches(self, db):
        m = im.match(db, im.classify_file(f"{WGS}/Glenfiddich 18 Year Old/GLENFIDDICH_18YO_EMPTY_750ML_FRONT.png"))
        assert m.line is None

    def test_no_words_is_the_namesake_line_and_a_missing_brand_is_a_stop(self, db):
        m = im.match(db, im.classify_file(f"{UPLOAD}/Tullamore Dew.jpg"))
        assert m.line.id == 22
        m = im.match(db, im.classify_file(f"{WGS}/Milagro Silver/Milagro Silver.jpeg"))
        assert m.brand is None and m.reason == "brand not found: 'Milagro Silver'"

    def test_a_logo_resolves_its_brand_only(self, db):
        _, logo = im.classify_csv_row({im.CSV_BRAND: "Glenfiddich", im.CSV_TITLE: "x", im.CSV_FRONT: "u", im.CSV_LOGO: "l"})
        m = im.match(db, logo)
        assert m.brand.slug == "glenfiddich" and m.line is None and m.reason is None


def _entries(db, paths, **kw):
    entries, summary = im.build(db, [im.classify_file(p) for p in paths], maps=load_maps(db), **kw)
    return entries, summary


def _by(entries, level, chosen=None):
    return [e for e in entries if e.level == level and (chosen is None or e.chosen == chosen)]


class TestBuild:
    FOLDER = f"{WGS}/Glenfiddich 21 Year Old"

    def test_two_candidates_of_equal_quality_leave_the_line_empty(self, db):
        entries, summary = _entries(db, [f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png",
                                         f"{self.FOLDER}/GLENFIDDICH_750ML_21_YEAR_BOTTLE_TRANSPARENT.png"])
        line = _by(entries, "line")
        assert len(line) == 2 and not any(e.chosen for e in line)
        assert all(e.target == "line:line-13" and e.reason.startswith("2 candidates of equal quality") for e in line)
        assert summary["line"]["chosen"] == 0 and summary["line"]["top_reasons"][0]["reason"] == "2 candidates of equal quality"

    def test_the_best_quality_wins_and_the_rest_name_it(self, db):
        entries, _ = _entries(db, [f"{self.FOLDER}/GLENFIDDICH_750ML_21YO_BOTTLE_AND_BOX_TRANSPARENT.png",
                                   f"{self.FOLDER}/GLENFIDDICH_750ML_21YO_BOTTLE_FRE.jpg",
                                   f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png"])
        chosen = _by(entries, "line", True)
        assert len(chosen) == 1 and chosen[0].source_path.endswith("SHOT_750ML_TRANSPARENT.png")
        assert chosen[0].reason == "best front bottle shot for the line (transparent, 750 ml)"
        assert {e.reason for e in _by(entries, "line", False)} == {"outranked by GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png"}
        # No variant at 750 ml: the variant entries stay empty with the size in the reason.
        assert all(e.target is None and "750 ml" in e.reason for e in _by(entries, "variant"))

    def test_one_shot_in_two_formats_is_one_candidate_and_the_png_stands(self, db):
        entries, _ = _entries(db, [f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.jpg",
                                   f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png"])
        chosen = _by(entries, "line", True)
        assert len(chosen) == 1 and chosen[0].source_path.endswith(".png")

    def test_the_representative_variants_size_is_preferred_then_700_or_750(self, db):
        folder = f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt"
        paths = [f"{folder}/TULLAMORE_14YO_700ML_FRONT.png", f"{folder}/TULLAMORE_14YO_1L_FRONT.png"]
        entries, _ = _entries(db, paths, representative_ml=lambda line: 1000)
        assert _by(entries, "line", True)[0].source_path.endswith("1L_FRONT.png")
        assert "the representative variant's size" in _by(entries, "line", True)[0].reason
        entries, _ = _entries(db, paths, representative_ml=lambda line: None)
        assert _by(entries, "line", True)[0].source_path.endswith("700ML_FRONT.png")
        assert {e.target for e in _by(entries, "variant", True)} == {"variant:201", "variant:202"}

    def test_byte_duplicates_whose_names_disagree_are_refused_and_agreeing_twins_stand_once(self, db):
        folder = f"{WGS}/Glenfiddich Grand Couronne"
        a, b = f"{folder}/GLENFIDDICH_750ML_GRANDE_COURONNE_BOTTLE_SHOT_RACK_TRANSPARENT.png", f"{folder}/GLENFIDDICH_750ML_GRANDE_COURONNE_PACK_TRANSPARENT.png"
        c, d = f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png", f"{self.FOLDER}/GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT (2).png"
        entries, _ = _entries(db, [a, b, c, d], digests={a: "same1", b: "same1", c: "same2", d: "same2"})
        reasons = {e.source_path: e.reason for e in entries if e.level == "variant"}
        assert reasons[a].startswith("byte duplicates whose names disagree") and reasons[a] == reasons[b]
        assert reasons[d].startswith("byte duplicate of GLENFIDDICH_21YO_BOTTLE_SHOT_750ML_TRANSPARENT.png")
        assert [e.source_path for e in _by(entries, "line", True)] == [c]

    def test_a_variant_only_shot_never_serves_the_line(self, db):
        entries, _ = _entries(db, [f"{WGS}/Tullamore D.E.W. 14 Year Old Single Malt/TULLAMORE_14YO_700ML_FRONT_WITH_SASH.png"])
        assert _by(entries, "variant", True)[0].target == "variant:201" and not _by(entries, "line")

    def test_a_brand_logo_once_per_brand_and_two_logos_are_nobodys(self, db):
        rows = [{im.CSV_BRAND: "Glenfiddich", im.CSV_TITLE: "x, y", im.CSV_FRONT: "", im.CSV_LOGO: "https://l/g.png"},
                {im.CSV_BRAND: "Glenfiddich", im.CSV_TITLE: "x, y", im.CSV_FRONT: "", im.CSV_LOGO: "https://l/g.png"},
                {im.CSV_BRAND: "Tullamore Dew", im.CSV_TITLE: "x, y", im.CSV_FRONT: "", im.CSV_LOGO: "https://l/t1.png"},
                {im.CSV_BRAND: "Tullamore Dew", im.CSV_TITLE: "x, y", im.CSV_FRONT: "", im.CSV_LOGO: "https://l/t2.png"},
                {im.CSV_BRAND: "Milagro", im.CSV_TITLE: "x, y", im.CSV_FRONT: "", im.CSV_LOGO: "https://l/m.png"}]
        entries, summary = im.build(db, [c for r in rows for c in im.classify_csv_row(r)])
        brand = {e.source_url: e for e in _by(entries, "brand")}
        assert {u: (e.target, e.chosen) for u, e in brand.items()} == {
            "https://l/g.png": ("brand:glenfiddich", True), "https://l/t1.png": ("brand:tullamore-dew", False),
            "https://l/t2.png": ("brand:tullamore-dew", False), "https://l/m.png": (None, False)}
        assert brand["https://l/t1.png"].reason == "2 different logos for Tullamore Dew"
        assert brand["https://l/m.png"].reason == "brand not found: 'Milagro'"
        assert summary["brand"] == {"entries": 4, "with_target": 3, "chosen": 1, "targets": 1, "unassigned": 3,
                                    "top_reasons": [{"reason": "2 different logos", "count": 2}, {"reason": "brand not found", "count": 1}]}

    def test_an_export_row_by_barcode_serves_variant_and_line(self, db):
        row = {im.CSV_BRAND: "The Balvenie", im.CSV_TITLE: "The Balvenie DoubleWood 12 Year Old, 750 ml Bottle", im.CSV_UPC: "083664990412",
               im.CSV_GTIN: "10083664990419", im.CSV_NET: "750 ml", im.CSV_FRONT: "https://f/b.jpg", im.CSV_LOGO: ""}
        entries, _ = im.build(db, im.classify_csv_row(row))
        assert [(e.level, e.target, e.chosen, e.gtin) for e in entries] == [
            ("line", "line:line-31", True, "0083664990412"), ("variant", "variant:311", True, "0083664990412")]
        assert entries[1].reason == "front shot of Balvenie DoubleWood 12 750ml (by barcode)"
        doc = im.manifest_document(entries, im.summarise(entries), folder=None, csv_path="x.csv")
        assert doc["schema"] == "dfp-image-manifest/1" and doc["entries"][0]["source_url"] == "https://f/b.jpg"
        assert doc["entries"][0]["image_source"] == "admin:pernod-ricard-via-adam" and doc["entries"][0]["licence"] == im.LICENCE


class TestCli:
    def test_images_manifest_writes_the_document_and_prints_the_counts(self, engine, db, tmp_path, monkeypatch, capsys):
        from app import cli_images
        from app.services import catalog_queries

        monkeypatch.setattr(cli_images, "SessionLocal", sessionmaker(bind=engine, autoflush=False, expire_on_commit=False, future=True))
        monkeypatch.setattr(catalog_queries, "get_product_line", lambda db, slug: None)
        folder = tmp_path / "pictures"
        line = folder / "Duty Free Professor Imagery-1" / "Duty Free Professor Imagery" / "Tullamore D.E.W. 14 Year Old Single Malt"
        line.mkdir(parents=True)
        (line / "TULLAMORE_14YO_700ML_FRONT.png").write_bytes(b"png-a")
        (line / "14 - LAY BACK.png").write_bytes(b"png-b")
        (line / "14 - LAY BACK copy.png").write_bytes(b"png-b")
        export = tmp_path / "products.csv"
        with open(export, "w", newline="", encoding="utf-8") as fh:
            w = csv.DictWriter(fh, fieldnames=[im.CSV_BRAND, im.CSV_TITLE, im.CSV_UPC, im.CSV_GTIN, im.CSV_NET, im.CSV_FRONT, im.CSV_LOGO])
            w.writeheader()
            w.writerow({im.CSV_BRAND: "Glenfiddich", im.CSV_TITLE: "Glenfiddich 21 Year Old, 700 ml", im.CSV_UPC: "", im.CSV_GTIN: "10000000000000",
                        im.CSV_NET: "700 ml", im.CSV_FRONT: "https://f/21.png", im.CSV_LOGO: "https://l/g.png"})
        out = tmp_path / "import" / "images" / "m.json"
        from app.cli import main

        assert main(["images", "manifest", "--folder", str(folder), "--csv", str(export), "--out", str(out)]) == 0
        printed = capsys.readouterr().out
        assert "brand    entries=1 with_target=1 chosen=1" in printed and "line     entries=2" in printed
        doc = json.loads(out.read_text())
        assert doc["schema"] == "dfp-image-manifest/1" and doc["folder"] == str(folder)
        by_source = {(e.get("source_path") or e.get("source_url"), e["level"]): e for e in doc["entries"]}
        assert by_source[("https://f/21.png", "variant")]["target"] == "variant:131" and by_source[("https://f/21.png", "variant")]["chosen"]
        assert by_source[("https://f/21.png", "line")]["target"] == "line:line-13"
        assert by_source[("https://l/g.png", "brand")]["target"] == "brand:glenfiddich"
        rel = "Duty Free Professor Imagery-1/Duty Free Professor Imagery/Tullamore D.E.W. 14 Year Old Single Malt"
        assert by_source[(f"{rel}/TULLAMORE_14YO_700ML_FRONT.png", "variant")]["target"] == "variant:201"
        assert by_source[(f"{rel}/14 - LAY BACK copy.png", "variant")]["reason"] == "a back view: not a front bottle shot"
        assert "url" not in by_source[(f"{rel}/TULLAMORE_14YO_700ML_FRONT.png", "line")]  # stage writes url and thumb_url

    def test_a_missing_folder_is_refused(self, tmp_path):
        from app.cli import main

        assert main(["images", "manifest", "--folder", str(tmp_path / "nope"), "--out", str(tmp_path / "m.json")]) == 2
