#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AU-IBAR National Livestock Contribution Briefs — per-country chart engine (refactored).

Refactored 2026-08-14 per ARCHITECTURE-REDESIGN.md, VISUAL-SPEC.md, PROVENANCE-SPEC.md,
and CAPTION-SPEC.md. Introduces a FigureBuilder base class, StyleConfig, CaptionManager,
and SidecarWriter to eliminate ~210 lines of repeated boilerplate across 10 builders.

Figures built (catalogue order):
  FIG-GDP, FIG-POP-TREND, FIG-HERD-COMP, FIG-SYSTEM, FIG-INCOME, FIG-EMPLOY,
  FIG-TRADE-ICEBERG, FIG-EXPORT-TREND, FIG-NUTRITION, FIG-GAPS.

Design contract honoured:
  * Source + year on every figure face (footer), verbatim from the data, no re-rounding.
  * Data-driven: reads the master extraction CSV
    (``extraction/data/livestock_briefs_data.csv``) directly, adapts each row to
    the former datahub-sheet schema in-memory (adapter verified value-identical
    to the datahub out/ sheets), and plots EXACTLY the rows pinned in
    ``design/figures/manifests/<ISO3>.<FIG-TYPE>.pins.json`` (no ranking).
  * Honours ``reviews/FLAGGED_FIGURE_REGISTER.md`` — generalized conflict layer.
  * Single-country only — NO cross-country / continental comparison.
  * Skip-with-note if an indicator's data is missing — never fabricate.

Outputs (writes ONLY under design/):
  * design/figures/<ISO3>/<fig-id>.svg (+ .png at 300 DPI)
  * design/figure-data/<ISO3>/<fig-id>.md   (provenance sidecar)
  * design/figure-data/<ISO3>/_caption_state.json  (per-country numbering)

Usage:
    python design/figures/build_country_figures.py --iso3 KEN
    python design/figures/build_country_figures.py --iso3 KEN --target print
    python design/figures/build_country_figures.py --all
"""
import os
import re
import sys
import json
import argparse
import csv
import datetime
from dataclasses import dataclass, field

# Never write .pyc bytecode into the Synthesis Brief tree.
sys.dont_write_bytecode = True

import pandas as pd
import numpy as np
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
from matplotlib.lines import Line2D
from matplotlib.patches import FancyBboxPatch, Patch
from matplotlib.colors import LinearSegmentedColormap

# ------------------------------------------------------------------ paths
THIS_DIR      = os.path.dirname(os.path.abspath(__file__))          # .../design/figures
DESIGN_DIR    = os.path.dirname(THIS_DIR)                           # .../design
BRIEFS_DIR    = os.path.dirname(DESIGN_DIR)                         # .../Briefs
MASTER_DATA   = os.path.join(BRIEFS_DIR, "extraction", "data",
                             "livestock_briefs_data.csv")           # single data source
MANIFEST_DIR  = os.path.join(THIS_DIR, "manifests")                 # design/figures/manifests
STD_DIR       = os.path.join(BRIEFS_DIR, "extraction", "standardization")
OUT_FIG_ROOT  = THIS_DIR                                            # design/figures/<ISO3>
OUT_DATA_ROOT = os.path.join(DESIGN_DIR, "figure-data")            # design/figure-data/<ISO3>

os.makedirs(OUT_FIG_ROOT, exist_ok=True)
os.environ["SYNTHESIS_VISUALS"] = OUT_FIG_ROOT

# ------------------------------------------------------------------ house style
sys.path.insert(0, os.path.join(BRIEFS_DIR, "Synthesis Brief", "visuals"))
from charts.common import (                                         # noqa: E402
    plt, np, matplotlib, style_axes, add_source_footer, wrap_label,
    AU_GREEN_DARK, AU_GREEN, AU_GREEN_MID, AU_GREEN_LIGHT, AU_TEAL,
    AU_GOLD, AU_GOLD_SOFT, AU_GOLD_PALE, AU_MAROON, AU_MAROON_SOFT, AU_GREY,
    AU_GREY_LIGHT, BG_CANVAS, BG_SURFACE, BG_TONAL, BORDER_DEFAULT, BORDER_SUBTLE,
    FG_PRIMARY, FG_SECONDARY, FG_TERTIARY, WHITE, GRID_COLOR,
    SPECIES_COLORS, SPECIES_LABELS, Patch, FancyBboxPatch,
)

TODAY = datetime.date.today().isoformat()

# Secondary encoding for the fixed AU SPECIES palette (VISUAL-SPEC §6.2).
SPECIES_MARKERS = {"cattle": "o", "sheep": "s", "goats": "^", "camels": "D"}
SPECIES_ORDER   = ["cattle", "sheep", "goats", "camels"]

# Register flags.
FLAG_SET = {"conflict", "suspected_error", "tier_conflict", "tier_flagged"}

# Catalogue order (CAPTION-SPEC §1.1).
CATALOGUE_ORDER = [
    "FIG-GDP", "FIG-POP-TREND", "FIG-HERD-COMP", "FIG-SYSTEM", "FIG-INCOME",
    "FIG-EMPLOY", "FIG-TRADE-ICEBERG", "FIG-EXPORT-TREND", "FIG-NUTRITION", "FIG-GAPS",
]

ALL_ISO3 = ["KEN", "ETH", "SOM", "UGA", "NGA", "MLI", "CMR", "TCD", "MRT", "SDN"]


# ================================================================== StyleConfig
@dataclass
class StyleConfig:
    """Centralized style configuration (ARCHITECTURE-REDESIGN §5, VISUAL-SPEC §7)."""

    # Output
    dpi_screen: int = 200
    dpi_print: int = 300
    target: str = "print"        # "screen" | "print" — default to print-ready

    # Figure sizing (VISUAL-SPEC §2.1 — 6.5in single-column for print)
    base_width: float = 6.5
    base_height: float = 4.0
    width_scale: float = 1.0     # render at authored size; PDF placement (6.5in) scales uniformly

    # Typography scale (VISUAL-SPEC §1.2)
    title_size: float = 14.0
    subtitle_size: float = 8.0
    axis_label_size: float = 9.0
    tick_label_size: float = 8.5
    annotation_size: float = 7.5

    # Caption (CAPTION-SPEC §4.3)
    caption_size: float = 8.5
    caption_color: str = FG_TERTIARY
    caption_align: str = "left"

    # Source footer (PROVENANCE-SPEC §1.1). Review round 2026-09-08: the self-
    # referencing "…National Livestock Brief — {cty}" tail is dropped — the caption
    # lane already names the document, and the tail was crowding out the money
    # note / extra context within the 2-line wrap.
    source_template: str = (
        "Source: {sources}. {year_prefix}{extra}"
    )
    source_size: float = 7.0

    # Margins
    margin_no_conflict: float = 0.05
    margin_with_conflict: float = 0.11
    margin_with_caption: float = 0.10   # reserve ~10% bottom for caption + source + integrity banner

    # Figheight of the figure currently being laid out (inches). Refreshed by
    # the FigureBuilder pipeline before calling bottom_margin(). A regular
    # dataclass field (with a safe default) so static analyzers see it; callers
    # never need to pass it. Content-scaled footer reservation (2026-09-09)
    # converts inch budgets to fig fractions with it.
    _last_figheight: float = 3.9

    @property
    def dpi(self) -> int:
        return self.dpi_print if self.target == "print" else self.dpi_screen

    def resolve_figsize(self, builder_figsize: tuple) -> tuple:
        w, h = builder_figsize
        return (w * self.width_scale, h * self.width_scale)

    def bottom_margin(self, has_conflicts: bool, has_caption: bool = True,
                      n_source_lines: int = 1, n_banner_lines: int = 0,
                      extra_footer_in: float = 0.0, fig_height: float = None) -> float:
        """Reserve exactly what the footer content needs — no dead band.

        Stack (bottom-up, in inches): source (n lines) [integrity banner lane,
        n actual wrapped lines] [builder footer block] [caption lane] + slack,
        scaled by the real figure height. Replaces the old content-blind
        margin_no_conflict/margin_with_caption constants that over-reserved
        ~0.3in for 1-line sources and under-reserved for 2-line ones.

        PITCH CALIBRATION (2026-09-09): per-line multipliers match rendered
        matplotlib text pitch in Carlito — source 1.26/line (7pt), caption
        1.34/line (8.5pt: 1.30 pitch + descender margin), banner 1.26/line
        (7.2pt) — replacing the nominal 1.2/1.25 that undershot the reserve.
        """
        _fh = fig_height if fig_height is not None else getattr(self, "_last_figheight", None)
        fh = max(float(_fh if _fh is not None else 3.9), 1e-6)
        src_line = (self.source_size / 72.0) * 1.26         # measured per-line height, in (Carlito 1.26×)
        cap_line = (self.caption_size / 72.0) * 1.34        # caption line + descender slack (1.30 pitch + margin)
        rb_in = 0.012 + max(n_source_lines, 1) * src_line + 0.008
        if has_conflicts:                                   # integrity banner lane (n actual lines)
            rb_in += 0.006 + max(n_banner_lines, 1) * (7.2 / 72.0) * 1.26 + 0.008
        if has_caption:
            rb_in += cap_line + 0.010
        rb_in += max(extra_footer_in, 0.0)
        return round((rb_in + 0.01) / fh, 3)

    def compose_source_footer(self, sources: str, year: str = "",
                               iso3: str = "", cty: str = "", extra: str = "") -> str:
        year_prefix = f"Data year {year}. " if year else ""
        extra_txt = f" {extra}" if extra else ""
        text = self.source_template.format(
            sources=sources, year_prefix=year_prefix, cty=cty, extra=extra_txt
        )
        if not text.rstrip().endswith("."):
            text = text.rstrip() + "."
        # Review rule (2026-09-08): never overflow the print column — wrap, max 2 lines.
        return wrap_source_line(text)

    # wrap_source_line is defined at module level further below; forward reference
    # is safe because compose_source_footer runs after module import completes.


# ================================================================== CaptionManager
class CaptionManager:
    """Per-country figure numbering and caption composition (CAPTION-SPEC §6)."""

    # Title templates per figure type (CAPTION-SPEC §2.2)
    TITLES = {
        "FIG-GDP":           "Livestock's contribution to the economy",
        "FIG-POP-TREND":     "Livestock population by species, over time",
        "FIG-HERD-COMP":     "Herd composition, latest year (by head)",
        "FIG-SYSTEM":        "Share of national herd by production system",
        "FIG-INCOME":        "Livestock share of pastoral household income",
        "FIG-EMPLOY":        "Employment in livestock (direct + indirect)",
        "FIG-TRADE-ICEBERG": "Livestock trade: formal vs informal",
        "FIG-EXPORT-TREND":  "Livestock export value, over time",
        "FIG-NUTRITION":     "Animal-source food & nutrition",
        "FIG-GAPS":          "Key data gaps by category",
    }

    def __init__(self, iso3: str, cty: str, data_root: str = None):
        self.iso3 = iso3
        self.cty = cty
        self.data_root = data_root or OUT_DATA_ROOT
        self.state_path = os.path.join(self.data_root, iso3, "_caption_state.json")
        self.state = self._load_state()

    def _load_state(self) -> dict:
        if os.path.exists(self.state_path):
            with open(self.state_path, "r", encoding="utf-8") as fh:
                return json.load(fh)
        return self._init_state()

    def _init_state(self) -> dict:
        figures = {}
        for i, fig_id in enumerate(CATALOGUE_ORDER, 1):
            title = self.TITLES.get(fig_id, fig_id)
            figures[fig_id] = {
                "number": i,
                "status": "pending",
                "caption": f"Figure {i}: {title} — {self.cty}",
            }
        return {
            "iso3": self.iso3,
            "country": self.cty,
            "catalogue_version": "2026-08-14",
            "figures": figures,
            "last_updated": datetime.datetime.now(datetime.timezone.utc).isoformat(),
        }

    def number_for(self, fig_id: str) -> int:
        return self.state["figures"].get(fig_id, {}).get("number", 0)

    def number_label(self, fig_id: str) -> str:
        n = self.number_for(fig_id)
        return f"Figure {n} ({self.iso3})"

    def compose(self, fig_id: str, title: str = "", year: str = "",
                source_text: str = "", status: str = "built",
                skip_reason: str = "") -> str:
        n = self.number_for(fig_id)
        t = title or self.TITLES.get(fig_id, fig_id)
        if status == "skipped":
            reason = skip_reason[:80] if skip_reason else "data not available"
            return f"Figure {n}: [Data not available — {reason}] — {self.cty}"
        return f"Figure {n}: {t} — {self.cty}"

    def compose_source_line(self, organization: str, year: str) -> str:
        yr = year if year else ""
        return f"Source: {organization}, {yr}. Data: AU-IBAR/APMD Data Hub."

    def place(self, fig: plt.Figure, caption_text: str,
              source_line: str = "", has_conflicts: bool = False,
              bottom_margin: float = 0.10) -> None:
        """Draw caption and source line on the figure face (CAPTION-SPEC §4).

        CAPTION-SOURCE OVERLAP FIX (2026-09-09): the caption used to anchor at a
        fixed fraction (rb*0.30 / rb*0.70) of a content-blind reserved margin, so
        2-line sources grew UP through it and 1-line sources left a dead band.
        Now the caption stacks ABOVE the MEASURED top of whatever footer artists
        already exist below rb (source line, integrity banner): lanes derive
        from rendered extents, so any line count is safe. Caller must draw the
        source line and integrity banner BEFORE calling place() (pipeline order).
        """
        rb = bottom_margin
        # Source line: bottom-most, centered, 7pt italic
        if source_line:
            fig.text(0.5, 0.012, source_line, ha="center", va="bottom",
                     fontsize=7, color=FG_TERTIARY, fontstyle="italic")
            fig.canvas.draw()
        # Measure the topmost footer artist already drawn below rb (source
        # lines, integrity banner). fig.texts includes title/suptitle artists;
        # the top < rb filter excludes them.
        ren = fig.canvas.get_renderer()
        fh_in = fig.get_figheight()
        lane_y = 0.012
        for t in fig.texts:
            bb = t.get_window_extent(ren)
            top = bb.y1 / (fig.dpi * fh_in)
            if top < rb and top > lane_y:
                lane_y = top
        # Caption: left-aligned, 8.5pt, FG_TERTIARY — anchored just above the
        # measured footer stack, never at a blind fraction of rb.
        fig.text(0.02, lane_y + 0.008, caption_text, ha="left", va="bottom",
                 fontsize=self.caption_size if hasattr(self, 'caption_size') else 8.5,
                 color=FG_TERTIARY, fontweight="normal")

    def metadata(self, fig_id: str) -> dict:
        n = self.number_for(fig_id)
        title = self.TITLES.get(fig_id, fig_id)
        return {
            "number": n,
            "number_label": f"Figure {n} ({self.iso3})",
            "caption_text": f"Figure {n}: {title} — {self.cty}",
            "source_line": "",
            "placement": "Below figure, left-aligned, 8.5pt Carlito, FG_TERTIARY (#6B7280)",
        }

    def save_state(self) -> None:
        self.state["last_updated"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
        os.makedirs(os.path.dirname(self.state_path), exist_ok=True)
        with open(self.state_path, "w", encoding="utf-8") as fh:
            json.dump(self.state, fh, indent=2, ensure_ascii=False)

    def update_figure_status(self, fig_id: str, status: str, caption: str = ""):
        if fig_id in self.state["figures"]:
            self.state["figures"][fig_id]["status"] = status
            if caption:
                self.state["figures"][fig_id]["caption"] = caption


# ================================================================== SidecarWriter
class SidecarWriter:
    """Writes and patches provenance sidecar markdown files (PROVENANCE-SPEC §6, CAPTION-SPEC §5)."""

    @staticmethod
    def write(iso3, cty, fig_id, title, chart_type, rows, sources_used,
              design_notes, integrity_notes, validation,
              caption_meta=None, rendering_meta=None):
        d = os.path.join(OUT_DATA_ROOT, iso3)
        os.makedirs(d, exist_ok=True)
        path = os.path.join(d, fig_id + ".md")
        L = []
        L.append(f"# {fig_id} — {title}")
        L.append("")
        L.append(f"*Provenance sidecar · {cty} ({iso3}) · generated {TODAY} by "
                 f"`design/figures/build_country_figures.py`.*")
        L.append("")
        L.append(f"- **Chart type:** {chart_type}")
        L.append(f"- **Country:** {cty} ({iso3}) — single-country figure (no cross-country comparison).")
        L.append("")

        # Caption section (CAPTION-SPEC §5.1)
        if caption_meta:
            L.append("## Caption")
            L.append("")
            L.append(f"- **Figure number:** {caption_meta.get('number_label', '')}")
            L.append(f"- **Caption text:** {caption_meta.get('caption_text', '')}")
            L.append(f"- **Source line:** {caption_meta.get('source_line', '')}")
            L.append(f"- **Placement:** {caption_meta.get('placement', '')}")
            L.append("")

        L.append("## Plotted values (verbatim from the datahub extract)")
        L.append("")
        L.append("| Indicator (id) | Value (verbatim) | Unit | Year | Source (tier) | Provenance | Flag |")
        L.append("|---|---|---|---|---|---|---|")
        for r in rows:
            L.append("| {ind} (`{code}`) | {val} | {unit} | {yr} | {srcv} | {prov} | {flag} |".format(
                ind=r.get("ind", ""), code=r.get("code", ""), val=r.get("val", ""),
                unit=r.get("unit", ""), yr=r.get("yr", ""), srcv=r.get("src", ""),
                prov=r.get("prov", ""), flag=r.get("flag", "") or "—"))
        L.append("")
        L.append("## Sources on the figure face")
        for s in sources_used:
            L.append(f"- {s}")
        L.append("")
        if design_notes:
            L.append("## Design / house-style notes")
            for n in design_notes:
                L.append(f"- {n}")
            L.append("")
        if integrity_notes:
            L.append("## Integrity notes (flagged register / provenance)")
            for n in integrity_notes:
                L.append(f"- {n}")
            L.append("")
        if validation:
            L.append("## Validation (plotted vs source)")
            for v in validation:
                L.append(f"- {v}")
            L.append("")

        # Rendering metadata (CAPTION-SPEC §5.2)
        if rendering_meta:
            L.append("## Rendering metadata")
            L.append("")
            for line in rendering_meta:
                L.append(f"- {line}")
            L.append("")

        with open(path, "w", encoding="utf-8") as fh:
            fh.write("\n".join(L))
        return path

    @staticmethod
    def write_skip(iso3, cty, fig_id, title, reason, detail=None):
        d = os.path.join(OUT_DATA_ROOT, iso3)
        os.makedirs(d, exist_ok=True)
        path = os.path.join(d, fig_id + ".md")
        L = [f"# {fig_id} — {title}", "",
             f"*Provenance sidecar · {cty} ({iso3}) · generated {TODAY} by "
             f"`design/figures/build_country_figures.py`.*", "",
             "- **Status:** SKIPPED — figure not built.",
             f"- **Data-gap reason:** {reason}"]
        if detail:
            L += ["", "## Detail", detail]
        L += ["", "> Design contract: *skip-with-note if the data is absent — never fabricate.* "
              f"This figure renders automatically once the required indicator(s) are present in the "
              f"datahub extract for {cty}."]
        with open(path, "w", encoding="utf-8") as fh:
            fh.write("\n".join(L))
        return path

    @staticmethod
    def update_caption(iso3, fig_id, caption_meta):
        """Patch the Caption section of an existing sidecar file (CAPTION-SPEC §5.1)."""
        d = os.path.join(OUT_DATA_ROOT, iso3)
        path = os.path.join(d, fig_id + ".md")
        if not os.path.exists(path):
            return None
        with open(path, "r", encoding="utf-8") as fh:
            content = fh.read()
        # Build replacement caption block
        cap_lines = ["## Caption", ""]
        cap_lines.append(f"- **Figure number:** {caption_meta.get('number_label', '')}")
        cap_lines.append(f"- **Caption text:** {caption_meta.get('caption_text', '')}")
        cap_lines.append(f"- **Source line:** {caption_meta.get('source_line', '')}")
        cap_lines.append(f"- **Placement:** {caption_meta.get('placement', '')}")
        cap_lines.append("")
        cap_block = "\n".join(cap_lines)
        # Replace existing ## Caption section or insert after provenance line
        import re
        if "## Caption" in content:
            content = re.sub(
                r"## Caption\n.*?(?=\n## |\n\Z)", cap_block, content,
                flags=re.DOTALL
            )
        else:
            # Insert after the provenance line (first blank line after the header)
            content = re.sub(
                r"(\*Provenance sidecar.*?\*\n\n)",
                r"\1" + cap_block,
                content, count=1, flags=re.DOTALL
            )
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(content)
        return path

    @staticmethod
    def update_rendering(iso3, fig_id, rendering_meta):
        """Patch the Rendering metadata section of an existing sidecar file (CAPTION-SPEC §5.2)."""
        d = os.path.join(OUT_DATA_ROOT, iso3)
        path = os.path.join(d, fig_id + ".md")
        if not os.path.exists(path):
            return None
        with open(path, "r", encoding="utf-8") as fh:
            content = fh.read()
        # Build replacement rendering block
        ren_lines = ["## Rendering metadata", ""]
        for line in rendering_meta:
            ren_lines.append(f"- {line}")
        ren_lines.append("")
        ren_block = "\n".join(ren_lines)
        # Replace existing ## Rendering metadata section or append
        import re
        if "## Rendering metadata" in content:
            content = re.sub(
                r"## Rendering metadata\n.*?(?=\n## |\n\Z)", ren_block, content,
                flags=re.DOTALL
            )
        else:
            # Append at end of file
            content = content.rstrip() + "\n\n" + ren_block
        with open(path, "w", encoding="utf-8") as fh:
            fh.write(content)
        return path


# ================================================================== data layer
def load_master():
    """Single data chokepoint: the master extraction CSV (STEP 2a).

    Replaces the former DATAHUB_DIR/REF_SourceRecords/DATA_* multi-file path.
    dtype=str + keep_default_na=False + utf-8-sig preserve the exact semantics
    the extraction pipeline (and the verified row adapter) were built on.
    """
    return pd.read_csv(MASTER_DATA, dtype=str, keep_default_na=False,
                       encoding="utf-8-sig")


_MASTER_ROWS = load_master().to_dict("records")
_MASTER_BY_ID = {r.get("data_point_id"): r for r in _MASTER_ROWS}

# ---- source registry (derive_pins.build_sources / source_id_for, verified) ----
def _build_source_keyed(rows):
    """Cited rows -> keyed (iso3, source_text, tier) sorted map producing
    BRIEF-<ISO3>-S<NN>; uncited rows -> BRIEF-<ISO3>-UNCITED. Verified 0 diffs
    vs the old REF_SourceRecords codes/labels on all 435 (iso3, source, tier)
    combos."""
    keyed, per_country, uncited = {}, {}, set()
    for r in rows:
        iso3 = r.get("country_iso3", "")
        s = (r.get("source") or "").strip()
        st = (r.get("source_type") or "").strip()
        tier = (r.get("source_tier") or "").strip()
        if not s or st == "uncited" or tier == "uncited":
            uncited.add(iso3)
            keyed[(iso3, s, tier)] = None
            continue
        per_country.setdefault(iso3, set()).add((s, tier))
    for iso3 in sorted(per_country):
        for i, (s, tier) in enumerate(sorted(per_country[iso3]), start=1):
            keyed[(iso3, s, tier)] = "BRIEF-%s-S%02d" % (iso3, i)
    for iso3 in sorted(uncited):
        sr_code = "BRIEF-%s-UNCITED" % iso3
        for k in list(keyed):
            if k[0] == iso3 and keyed[k] is None:
                keyed[k] = sr_code
    return keyed


_SR_KEYED = _build_source_keyed(_MASTER_ROWS)


def source_id_for(r):
    """Master row -> Source_ID code (derive_pins.source_id_for, verified identical)."""
    iso3 = r.get("country_iso3", "")
    s = (r.get("source") or "").strip()
    tier = (r.get("source_tier") or "").strip()
    st = (r.get("source_type") or "").strip()
    if not s or st == "uncited" or tier == "uncited":
        return "BRIEF-%s-UNCITED" % iso3
    return _SR_KEYED.get((iso3, s, tier), "BRIEF-%s-UNCITED" % iso3)


def _tier_T(raw):
    raw = (raw or "").strip()
    return ("T" + raw) if raw in {"1", "2", "3", "4", "5", "6", "7"} else ""


def load_sources():
    """SOURCES rebuilt from the master CSV (STEP 2c). Cited rows carry their own
    source text + tier (T<tier>); uncited-but-with-text rows -> BRIEF-<ISO3>-UNCITED
    with label '(uncited in brief)'. Labels identical to the old REF labels."""
    out = {}
    for (iso3, s, tier), code in _SR_KEYED.items():
        if not code:
            continue
        if code.endswith("-UNCITED"):
            out[code] = ("(uncited in brief)", "")
        else:
            out[code] = (s, _tier_T(tier))
    return out


# ---- master row -> datahub-sheet adapter (derive_pins.build_obs transform) ----
_DATA_TYPE_SHEET = {"gdp": "DATA_GDP", "trade": "DATA_Trade",
                    "livestock": "DATA_Livestock_Pop", "nutrition": "DATA_Nutrition",
                    "employment": "DATA_Employment", "household": "DATA_Household"}


def _load_ind_sheet_map():
    """indicator name -> (sheet, datahub_code), via the dictionary (name->id)
    then indicator_datahub_map (id->datahub row), observation rows only."""
    with open(os.path.join(STD_DIR, "indicator_dictionary.csv"),
              encoding="utf-8-sig") as fh:
        name_to_iid = {r["indicator_name"]: r["indicator_id"] for r in csv.DictReader(fh)}
    with open(os.path.join(BRIEFS_DIR, "extraction", "datahub",
                           "indicator_datahub_map.csv"), encoding="utf-8-sig") as fh:
        iid_to_mp = {r["indicator_id"]: r for r in csv.DictReader(fh)}
    out = {}
    for name, iid in name_to_iid.items():
        mp = iid_to_mp.get(iid)
        if mp is None or mp.get("emit_as") != "observation":
            continue
        sheet = _DATA_TYPE_SHEET.get(mp.get("datahub_data_type", ""))
        if sheet:
            out[name] = (sheet, mp.get("datahub_indicator_code", ""))
    return out


_IND_SHEET_MAP = _load_ind_sheet_map()

_COMMON_COLS = ["Country_Code", "Country_Name", "Year", "Value", "Unit",
                "Data_Quality", "Source_ID", "Approval_Status", "Flag_Reason",
                "Note", "Captured_By", "Indicator_Code_Raw", "Unit_Raw",
                "Country_Code_Raw", "Disaggregation_Json"]
_SHEET_COLS = {
    "DATA_GDP": _COMMON_COLS + ["Indicator_Code", "Indicator_Name", "Currency",
                                "Reference_Year", "Brief_Ref"],
    "DATA_Trade": _COMMON_COLS + ["Indicator_Code", "Indicator_Name", "Trade_Flow",
                                  "Partner_Country", "Commodity", "Species_Name",
                                  "Formality", "Brief_Ref"],
    "DATA_Livestock_Pop": _COMMON_COLS + ["Species_Name", "Species_Code",
                                          "Geographic_Level", "Admin_Zone",
                                          "Production_System", "Breed", "Brief_Ref"],
    "DATA_Nutrition": _COMMON_COLS + ["Indicator_Code", "Indicator_Name",
                                      "Age_Group", "Sex", "Brief_Ref"],
    "DATA_Employment": _COMMON_COLS + ["Indicator_Code", "Indicator_Name",
                                       "Sector", "Sex", "Brief_Ref"],
    "DATA_Household": _COMMON_COLS + ["Indicator_Code", "Indicator_Name",
                                      "Household_Type", "Brief_Ref"],
    "DATA_Gaps": ["Gap_ID", "Country", "Indicator", "Gap_Type", "Severity",
                  "Description"],
}
_SPECIES_BY_NAME = {"Total Cattle Population": ("cattle", "cattle"),
                    "Total Sheep Population": ("sheep", "sheep"),
                    "Total Goat Population": ("goats", "goats"),
                    "Total Camel Population": ("camels", "camels")}
_EXCLUDED_FLAGS = {"superseded", "placeholder_skipped", "not_on_final"}


def _year_int(raw):
    m = re.search(r"\d{4}", raw or "")
    return m.group(0) if m else ""


def _data_quality(vq, st):
    if st == "apmd_calculated" or vq == "calculated":
        return "APMD_Calculated"
    if st == "apmd_estimated" or vq == "estimated":
        return "APMD_Estimated"
    if vq in {"approx", "range_low", "range_high"}:
        return "Approximate"
    if vq == "projected":
        return "Projected"
    if vq == "derived":
        return "Derived"
    return "Reported"


def _approval(cf):
    if cf in FLAG_SET:
        return "FLAGGED", cf
    return "NEEDS_REVIEW", ""


def adapt_row(r):
    """Master CSV row -> former datahub-sheet observation row.

    The derive_pins.build_obs() transform (proven value-identical to the datahub
    out/ sheets). Brief_Ref is a SEPARATE column — Source_ID is NEVER overwritten
    with it. Only rows to_datahub emitted as observations are adapted.
    """
    cf = (r.get("confidence_flag") or "").strip()
    if cf in _EXCLUDED_FLAGS:
        return None  # emit_kind 'skip' — the datahub path never emitted these
    if not (r.get("value_numeric") or "").strip():
        return None  # emit_kind 'context' — no value, never an observation row
    mp = _IND_SHEET_MAP.get((r.get("indicator") or "").strip())
    if mp is None:
        return None  # not an observation on a builder-read sheet
    sheet, dh_code = mp
    vq = (r.get("value_qualifier") or "").strip()
    st = (r.get("source_type") or "").strip()
    appr, reason = _approval(cf)
    yr = _year_int(r.get("year"))
    note_bits = ["brief_ref=" + (r.get("data_point_id") or "")]
    if cf and cf != "ok":
        note_bits.append("flag=" + cf)
    if (r.get("notes") or "").strip():
        note_bits.append(r["notes"].strip())
    disagg = (r.get("disaggregation") or "").strip()
    obs = {k: "" for k in _SHEET_COLS[sheet]}
    obs["_sheet"] = sheet  # consumed by _sheet_frames; never reaches a builder
    obs.update({
        "Country_Code": (r.get("country_iso3") or "").strip(),
        "Country_Name": r.get("country") or "",
        "Year": yr,
        "Value": (r.get("value_numeric") or "").strip(),
        "Unit": (r.get("unit") or "").strip(),
        "Data_Quality": _data_quality(vq, st),
        "Source_ID": source_id_for(r),
        "Approval_Status": appr,
        "Flag_Reason": reason,
        "Note": "; ".join(note_bits),
        "Captured_By": r.get("extracted_by") or "",
        "Indicator_Code_Raw": r.get("indicator") or "",
        "Unit_Raw": (r.get("unit") or "").strip(),
        "Country_Code_Raw": (r.get("country_iso3") or "").strip(),
        "Indicator_Code": dh_code,
        "Indicator_Name": (r.get("indicator") or "").strip(),
        "Brief_Ref": r.get("data_point_id") or "",
    })
    if disagg:
        obs["Disaggregation_Json"] = json.dumps({"disaggregation": disagg},
                                                ensure_ascii=False)
    if sheet == "DATA_Livestock_Pop":
        # The real DATA_Livestock_Pop sheet carries NO Indicator_Code /
        # Indicator_Name columns (species columns identify the row); the conflict
        # layer must fall back to Note brief_ref labels exactly as before.
        obs.pop("Indicator_Code", None)
        obs.pop("Indicator_Name", None)
        sp = _SPECIES_BY_NAME.get((r.get("indicator") or "").strip())
        if sp:
            obs["Species_Name"], obs["Species_Code"] = sp
        obs["Production_System"] = disagg
    if sheet == "DATA_Household":
        obs["Household_Type"] = disagg
    if sheet == "DATA_Trade":
        obs["Trade_Flow"] = ("export" if "EXPORT" in dh_code else
                             "import" if "IMPORT" in dh_code else "")
        obs["Commodity"] = ("hides_skins" if "HIDES" in dh_code else
                            "live_animals" if "LIVE" in dh_code else
                            "meat" if "MEAT" in dh_code else "")
        fm = (r.get("formality") or "").strip()
        obs["Formality"] = fm if fm in {"formal", "informal"} else ""
    if sheet == "DATA_GDP":
        obs["Currency"] = (r.get("currency") or "").strip()
        obs["Reference_Year"] = yr
    return obs


_SHEET_FRAMES_CACHE = None


def _sheet_frames():
    """All builder-read observations, grouped by former datahub sheet (cached)."""
    global _SHEET_FRAMES_CACHE
    if _SHEET_FRAMES_CACHE is None:
        frames = {}
        for r in _MASTER_ROWS:
            obs = adapt_row(r)
            if obs is None:
                continue
            frames.setdefault(obs.pop("_sheet"), []).append(obs)
        _SHEET_FRAMES_CACHE = frames
    return _SHEET_FRAMES_CACHE


def _load(name):
    """Compat chokepoint: former DATA_*.csv consumers now get the adapted
    master-CSV frame for `name` (e.g. 'DATA_GDP.csv' or 'DATA_GDP')."""
    sheet = name[:-4] if name.endswith(".csv") else name
    frames = _sheet_frames()
    return pd.DataFrame(frames.get(sheet, []))


def load_indicator_dict():
    p = os.path.join(STD_DIR, "indicator_dictionary.csv")
    with open(p, encoding="utf-8-sig") as fh:
        rows = list(csv.DictReader(fh))
    idx = {}
    for r in rows:
        idx[r["indicator_name"]] = dict(
            name=r.get("indicator_name", ""), section=r.get("section", ""),
            theme=r.get("theme", ""), unit=r.get("canonical_unit", ""),
            definition=r.get("definition", ""))
    return idx


SOURCES = load_sources()
INDDICT = load_indicator_dict()


def src(code):
    label, tier = SOURCES.get(code, (code, ""))
    return label, tier


def src_str(code):
    label, tier = src(code)
    return f"{label} ({tier})" if tier else label


def country_name(iso3):
    cd = pd.read_csv(os.path.join(STD_DIR, "country_dimension.csv"),
                     dtype=str, keep_default_na=False)
    row = cd[cd.country_iso3 == iso3]
    return row.iloc[0]["country_name"] if len(row) else iso3


# ================================================================== register / conflict layer
def _load_manifest(fig_id, iso3):
    """Manifest pins for one figure (STEP 2d). Missing manifest = fail loud
    naming the figure (STEP 4 guard i)."""
    p = os.path.join(MANIFEST_DIR, "%s.%s.pins.json" % (iso3, fig_id))
    if not os.path.exists(p):
        raise SystemExit(
            "FAIL [guard i: manifest missing]: figure %s/%s — expected manifest %s. "
            "Run derive_pins.py or restore the manifest before building."
            % (iso3, fig_id, p))
    with open(p, encoding="utf-8") as fh:
        m = json.load(fh)
    # STEP 4 guards (ii)+(iii): pinned ids exist in master; pinned units expected.
    ue = m.get("units_expected", [])
    for pin in m.get("pins", []):
        did = pin.get("data_point_id", "")
        if did not in _MASTER_BY_ID:
            raise SystemExit(
                "FAIL [guard ii: pinned id not in master]: figure %s/%s — pinned "
                "data_point_id %r (slot %s) not found in %s"
                % (iso3, fig_id, did, pin.get("slot", "?"), MASTER_DATA))
        unit = (pin.get("unit") or "").strip()
        if unit not in ue:
            raise SystemExit(
                "FAIL [guard iii: pinned unit out of expected set]: figure %s/%s — "
                "pinned row %r (slot %s) unit %r not in units_expected %s"
                % (iso3, fig_id, did, pin.get("slot", "?"), unit, ue))
    return m


def _pin_unit_check(dfp, manifest, slot, unit=None, allow_scaled_pin=False):
    """STEP 4 (iii) in-engine (A3): every selected plotted row's Unit must be in
    the figure's expected unit set. Fail loud naming figure + row id.

    Scaled-pin exception (option 1, task 2026-09-14 SOM): a row may sit OUTSIDE
    the expected-unit set only when its own pin declares an explicit numeric
    `scale` (SOM-S4-085: unit 'USD M', scale 1e6) — the resolver converts it
    before frame assembly. The exception is opt-in per call site; generic
    paths keep the strict check.
    """
    ue = manifest.get("units_expected", [])
    for _, r in dfp.iterrows():
        u = (str(r.get("Unit", "")) or "").strip()
        if not u or u in ue:
            continue
        if allow_scaled_pin:
            ref = r.get("Brief_Ref", "?")
            pin = next((p for p in manifest.get("pins", [])
                        if p.get("data_point_id") == ref
                        and (str(p.get("slot", "")) == slot or
                             str(p.get("slot", "")).startswith(slot + ":"))), None)
            if pin and fnum(pin.get("scale")):
                continue
        raise SystemExit(
            "FAIL [guard iii in-engine]: figure %s — plotted row id %r (slot %r) "
            "carries unit %r not in the figure's expected units %s"
            % (manifest.get("figure", "?"), r.get("Brief_Ref", "?"), slot, u, ue))


def _pin_filter(df, manifest, prefix):
    """Pin-driven filter for PLOTTED rows (STEP 2d): keep only rows whose
    Brief_Ref is pinned under slots equal to `prefix` or starting `prefix:`
    (e.g. 'series' covers 'series:EXPORT_LIVE'). Ranking is no longer used for
    plotted rows; a pinned id absent from the frame fails loud (guard ii)."""
    if df is None or not len(df):
        return df
    ids = [p.get("data_point_id", "") for p in manifest.get("pins", [])
           if str(p.get("slot", "")) == prefix or
           str(p.get("slot", "")).startswith(prefix + ":")]
    if not ids:
        return df.iloc[0:0]
    missing = sorted(set(ids) - set(df["Brief_Ref"]))
    if missing:
        raise SystemExit(
            "FAIL [guard ii in-engine]: figure %s — pinned row(s) %s for slot "
            "prefix %r not present in this figure's data frame"
            % (manifest.get("figure", "?"), missing, prefix))
    out = df[df["Brief_Ref"].isin(ids)]
    _pin_unit_check(out, manifest, prefix)
    return out


def _pin_pick(df, manifest, slot, unit=None):
    """Pinned single-row pick for an exact slot name (e.g. 'plotted'), or None
    when the slot has no pin. Pinned id absent from the frame fails loud
    (guard ii); the returned row's unit must be expected (guard iii).

    `unit` (optional, task 2026-09-14 SOM): the figure's common money unit.
    When given, the pick first tries rows in that unit; if the pin id is
    present in the frame ONLY under a different unit AND its pin declares an
    explicit `scale`, the derived-unit row is returned and the caller converts
    it (see the iceberg's PIN SCALE RESOLUTION). Rows carrying units neither
    expected nor scale-resolvable are invisible to the pick (fail-loud
    contract preserved via the guard above and the caller-side mismatch
    check)."""
    if df is None or not len(df):
        return None
    ids = [p.get("data_point_id", "") for p in manifest.get("pins", [])
           if str(p.get("slot", "")) == slot]
    if not ids:
        return None
    if unit:
        in_unit = df[df["Brief_Ref"].isin(ids) &
                     (df["Unit"].astype(str).str.strip().str.lower() ==
                      str(unit).strip().lower())]
        if len(in_unit):
            _pin_unit_check(in_unit, manifest, slot)
            return _clean_rank(in_unit).iloc[0]
        # Derived-unit fallback: pin present only under a different unit.
        rest = df[df["Brief_Ref"].isin(ids)]
        if len(rest):
            _pin_unit_check(rest, manifest, slot, allow_scaled_pin=True)
            return _clean_rank(rest).iloc[0]
    sub = df[df["Brief_Ref"].isin(ids)]
    if sub.empty:
        raise SystemExit(
            "FAIL [guard ii in-engine]: figure %s — pinned row(s) %s for slot %r "
            "not present in this figure's data frame"
            % (manifest.get("figure", "?"), ids, slot))
    _pin_unit_check(sub, manifest, slot)
    return _clean_rank(sub).iloc[0]


def _master_gap_rows():
    """In-engine derivation of the DATA_Gaps gap rows (STEP 2e, A2) — the exact
    derive_pins.replicate() gap logic (named_gap / tier_below_authority /
    tier_conflict / uncited_comparison_grade / checklist_miss), deterministically
    sorted, Gap_ID = GAP-<ISO3>-<NNN>."""
    import collections
    dictionary = INDDICT  # name -> {name, section, theme, unit, definition}
    with open(os.path.join(STD_DIR, "indicator_dictionary.csv"),
              encoding="utf-8-sig") as fh:
        dict_full = {r["indicator_name"]: r for r in csv.DictReader(fh)}
    with open(os.path.join(BRIEFS_DIR, "extraction", "datahub",
                           "indicator_datahub_map.csv"), encoding="utf-8-sig") as fh:
        ind_map = {r["indicator_id"]: r for r in csv.DictReader(fh)}
    with open(os.path.join(STD_DIR, "country_dimension.csv"),
              encoding="utf-8-sig") as fh:
        countries = {r["country_iso3"]: r for r in csv.DictReader(fh)}
    with open(os.path.join(STD_DIR, "indicator_dictionary.csv"),
              encoding="utf-8-sig") as fh:
        iid_by_name = {r["indicator_name"]: r["indicator_id"] for r in csv.DictReader(fh)}

    def gap_tag(disagg):
        d = (disagg or "").lower()
        for key, val in (("gdp", "GDP"), ("population", "Population"), ("trade", "Trade"),
                         ("household", "Household"), ("market", "Market")):
            if key in d:
                return val
        return ""

    gaps = []
    obs_index = {}
    for r in _MASTER_ROWS:
        iso3 = r["country_iso3"]
        if iso3 not in countries:
            continue
        drow = dict_full.get(r["indicator"])
        mp = ind_map.get(drow["indicator_id"]) if drow else None
        if mp is None:
            continue
        cf = (r["confidence_flag"] or "").strip()
        tier = (r["source_tier"] or "").strip()
        # emit_kind replica
        if cf in _EXCLUDED_FLAGS:
            kind = "skip"
        elif mp["emit_as"] == "data_gap":
            kind = "data_gap"
        elif mp["emit_as"] == "context":
            kind = "context"
        elif mp["datahub_data_type"] == "policy":
            kind = "observation"
        else:
            kind = "observation" if (r["value_numeric"] or "").strip() else "context"
        if kind == "skip":
            continue
        if kind == "data_gap":
            gaps.append({"source": "named_gap", "iso3": iso3, "country": r["country"],
                         "indicator": r["indicator"],
                         "gap_type": ("Named_Gap_" + gap_tag(r["disaggregation"])).rstrip("_"),
                         "severity": "High",
                         "description": (r["value_text"] or "").strip()})
            continue
        if kind == "context":
            continue
        obs_index.setdefault(iso3, set()).add(r["indicator"])
        if cf == "tier_flagged":
            gaps.append({"source": "tier_below_authority", "iso3": iso3, "country": r["country"],
                         "indicator": r["indicator"], "gap_type": "Below_Authority_Tier",
                         "severity": "Medium",
                         "description": "%s (%s) cited at tier %s; a T1-T3 figure should be sourced." %
                                        (r["indicator"], r["year"] or "n.d.", tier or "uncited")})
        elif cf == "tier_conflict":
            gaps.append({"source": "tier_conflict", "iso3": iso3, "country": r["country"],
                         "indicator": r["indicator"], "gap_type": "Tier_Conflict",
                         "severity": "Critical",
                         "description": "%s (%s) conflicts with its higher-tier counterpart (>10%%); resolve." %
                                        (r["indicator"], r["year"] or "n.d.")})
        if tier == "uncited" and drow.get("comparison_grade") == "Y" and (r["value_numeric"] or "").strip():
            gaps.append({"source": "uncited_comparison_grade", "iso3": iso3, "country": r["country"],
                         "indicator": r["indicator"], "gap_type": "Uncited_Source",
                         "severity": "Medium",
                         "description": "%s (%s) has no cited source; provenance needed before reuse." %
                                        (r["indicator"], r["year"] or "n.d.")})
    countries_seen = sorted(obs_index)
    for iso3 in countries_seen:
        cname = countries[iso3]["country_name"]
        for iid, mp in sorted(ind_map.items()):
            if mp["emit_as"] != "observation":
                continue
            drow = next((d for d in dict_full.values() if d["indicator_id"] == iid), None)
            if not drow or drow.get("comparison_grade") != "Y":
                continue
            if mp["indicator_name"] not in obs_index[iso3]:
                gaps.append({"source": "checklist_miss", "iso3": iso3, "country": cname,
                             "indicator": mp["indicator_name"], "gap_type": "Missing_Indicator",
                             "severity": "High",
                             "description": "Expected indicator '%s' has no value in the %s brief." %
                                            (mp["indicator_name"], cname)})
    seen, gap_rows = set(), []
    for g in gaps:
        k = (g["iso3"], g["gap_type"], g["indicator"], g["description"])
        if k in seen:
            continue
        seen.add(k)
        gap_rows.append(g)
    per, final_gaps = {}, []
    for g in sorted(gap_rows, key=lambda x: (x["iso3"], x["gap_type"], x["indicator"])):
        per[g["iso3"]] = per.get(g["iso3"], 0) + 1
        final_gaps.append({
            "Gap_ID": "GAP-%s-%03d" % (g["iso3"], per[g["iso3"]]),
            "Country": g["country"], "Indicator": g["indicator"],
            "Gap_Type": g["gap_type"], "Severity": g["severity"],
            "Description": g["description"],
        })
    return final_gaps


_GAPS_ROWS_CACHE = None


def _gaps_rows():
    global _GAPS_ROWS_CACHE
    if _GAPS_ROWS_CACHE is None:
        _GAPS_ROWS_CACHE = _master_gap_rows()
    return _GAPS_ROWS_CACHE


def _flag_of(rec):
    return str(rec.get("Flag_Reason", "") if hasattr(rec, "get") else rec).strip().lower()


def parse_note(note):
    note = note or ""
    ref = ""
    m = re.search(r"brief_ref=([A-Za-z0-9\-]+)", note)
    if m:
        ref = m.group(1)
    txt = re.sub(r"^\s*brief_ref=[^;]*;\s*flag=[^;]*;\s*", "", note).strip()
    return ref, (txt or note.strip())


def _clean_rank(df):
    d = df.copy()
    d["_fl"] = d.Flag_Reason.astype(str).str.strip().str.lower().isin(FLAG_SET).astype(int)
    d["_un"] = d.Source_ID.astype(str).str.contains("UNCITED", case=False, na=False).astype(int)
    return d


def pick(df):
    if df is None or not len(df):
        return None
    return _clean_rank(df).sort_values(["_fl", "_un"]).iloc[0]


def dedup_pick(df, keys):
    if not len(df):
        return df
    return (_clean_rank(df).sort_values(list(keys) + ["_fl", "_un"])
            .drop_duplicates(subset=list(keys), keep="first"))


def conflicts_in(df, label_col=None, name_col="Indicator_Name", cap=40):
    out = []
    if df is None or not len(df):
        return out
    fl = df[df.Flag_Reason.astype(str).str.strip().str.lower().isin(FLAG_SET)]
    seen = set()
    for _, rec in fl.iterrows():
        ref, txt = parse_note(rec.get("Note", ""))
        if label_col and label_col in rec.index and str(rec.get(label_col, "")):
            lbl = str(rec.get(label_col, ""))
            if label_col == "Species_Name":
                lbl = lbl.capitalize()
        elif name_col in rec.index and str(rec.get(name_col, "")):
            lbl = str(rec.get(name_col, ""))
        else:
            lbl = ref or "figure"
        key = (ref, str(rec.get("Value", "")), lbl, str(rec.get("Year", "")))
        if key in seen:
            continue
        seen.add(key)
        out.append(dict(label=lbl, flag=_flag_of(rec), ref=ref, value=rec.get("Value", ""),
                        unit=rec.get("Unit", ""), year=rec.get("Year", ""), text=txt))
        if len(out) >= cap:
            break
    return out


def conflict_integrity_lines(conflicts):
    if not conflicts:
        return ["No row relevant to this figure carries a `conflict` / `suspected_error` "
                "flag in FLAGGED_FIGURE_REGISTER.md."]
    lines = ["The following register-flagged value(s) are relevant to this figure — the "
             "authoritative value is plotted; the conflict is disclosed here and marked on the "
             "figure face (never treated as clean):"]
    for c in conflicts:
        mark = "‡" if c["flag"] == "conflict" else "⚠"
        unit = f" {c['unit']}" if c["unit"] else ""
        ref = f" [{c['ref']}]" if c["ref"] else ""
        yr = c["year"] or "n.d."
        lines.append(f"  {mark} **{c['label']} = {c['value']}{unit}** ({yr}) — `{c['flag']}`"
                     f"{ref}: {c['text']}")
    return lines


def _wrap_integrity_banner(n_conflicts: int, fig_width_in: float) -> list:
    """Single source of truth for the wrapped integrity-banner text.

    Both the pipeline's bottom-margin reservation (pre-layout) and
    add_integrity_footer (drawing) must agree on the wrapped banner line count,
    or the banner lane is mis-reserved. Char budget derives from the real fig
    width (0.50 em/char at 7.2pt) — the same standard as the banner itself.
    """
    if not n_conflicts:
        return []
    import textwrap as _tw
    _cpl = max(40, int(fig_width_in / (7.2 / 72.0 * 0.50)))
    msg = (f"‡ {n_conflicts} figure{'s' if n_conflicts != 1 else ''} relevant to this chart carr"
           f"{'y' if n_conflicts != 1 else 'ies'} a register conflict flag — authoritative value "
           "shown; conflict disclosed in the provenance record.")
    return _tw.wrap(msg, width=_cpl)


def _wrap_subtitle_lines(text: str, fig_w_in: float, fontsize: float = 9.0) -> list:
    """Face-text wrap for ax.text/fig.text subtitle lanes (std1 (d), 2026-09-09).

    Same standard as _wrap_integrity_banner and the HerdComp subtitle: text
    wraps to the AUTHORED figure width (0.55 em/char, conservative for the
    house mix of Carlito/digits), never widens the saved PNG. Multi-line
    va="bottom" blocks grow UP into the reserved title/subtitle lanes.
    """
    if not text:
        return []
    import textwrap as _tws
    _cpl = max(30, int(fig_w_in / (fontsize / 72.0 * 0.55)))
    return _tws.wrap(text, width=_cpl)


def add_integrity_footer(fig, conflicts, bottom_margin=0.10, source_text=None):
    """Draw the conflict-integrity banner above the measured source lane.

    Returns the number of wrapped banner lines (0 when no conflicts) so the
    caller can content-scale the reserved bottom margin for the banner lane
    (post-D extension: the wrapped banner may be 1 or 2 lines, and the
    reservation must account for the ACTUAL count).
    """
    if not conflicts:
        return 0
    n = len(conflicts)
    # OWNER RULE (2026-09-08): no internal issue/register codes on the figure face —
    # the reader needs the fact, not the reference. Refs live in the sidecar only.
    # Banner text/wrap lives in _wrap_integrity_banner() so the pipeline's
    # bottom-margin reservation uses the IDENTICAL wrapped text (no drift).
    msg = " ".join(_wrap_integrity_banner(n, fig.get_figwidth()))
    # Position within the reserved bottom margin — middle lane (caption is
    # placed at rb*0.70 when conflicts exist, source line at the very bottom;
    # add_integrity_footer receives the same rb so the lanes cannot collide).
    # Repair (2026-09-08): the source lane is variable-height, so the banner's
    # y is MEASURED from the actual wrapped source (line count x 7pt x matplotlib
    # 1.2 linespacing, scaled by the real figure height) — a fixed rb*0.42 let a
    # 3-line source collide with the banner.
    n_lines = len((source_text or "").split("\n")) if source_text else 0
    if n_lines:
        line_frac = (7.0 / 72.0) * 1.2 / fig.get_figheight()
        y_pos = 0.012 + n_lines * line_frac + 0.006
    else:
        y_pos = bottom_margin * 0.42
    # BANNER-WRAP FIX (2026-09-09): the banner was ONE unwrapped centered line —
    # its tight-bbox artist widened the saved PNG on conflict-carrying countries
    # (KEN/CMR/NGA ~2090px vs ~1890px; same defect class as the HerdComp subtitle).
    # OWNER RULE: face text wraps to the authored figure width, never widens the
    # image. Char budget derives from the real fig width (0.50 em/char at 7.2pt).
    # Multi-line va="bottom" grows UP; measured y_pos + 2 lines stays clear of the
    # caption lane at rb*0.70 (0.126 < 0.147 at rb=0.21, verified 2026-09-09).
    import textwrap as _tw
    _cpl = max(40, int(fig.get_figwidth() / (7.2 / 72.0 * 0.50)))
    _msg_lines = _tw.wrap(msg, width=_cpl)
    _msg = "\n".join(_msg_lines)
    fig.text(0.5, y_pos, _msg, ha="center", va="bottom", fontsize=7.2,
             color=AU_MAROON, fontstyle="italic")
    return len(_msg_lines)


def dedup_conflicts(conflicts):
    out, seen = [], set()
    for c in conflicts:
        k = (c["ref"], str(c["value"]), c["label"], str(c["year"]))
        if k in seen:
            continue
        seen.add(k)
        out.append(c)
    return out


# ================================================================== helpers
def fnum(s):
    try:
        return float(str(s).replace(",", ""))
    except (ValueError, TypeError):
        return None


def millions(x, dp=1):
    return f"{x/1e6:.{dp}f}M"


def persons_lbl(v):
    if v is None:
        return "—"
    if v >= 1e6:
        return f"{v/1e6:.2f}M"
    if v >= 1e3:
        return f"{v/1e3:.0f}k"
    return f"{v:g}"


def ink_on(hexcol):
    h = hexcol.lstrip("#")
    r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
    lum = 0.2126 * r + 0.7152 * g + 0.0722 * b
    return FG_PRIMARY if lum > 0.55 else WHITE


def usd(v):
    """USD on the chart face, words-scale compact (owner rule 2026-09-08: no
    scientific notation, ever). DEFECT FIX 2026-09-14 (NGA '2e+06'): the old
    f"\\${v:g}m" emitted e-notation whenever the extracted value is RAW dollars
    ($2,000,000 -> '$2e+06m'). Sibling labels ($110m, $150–200m) are millions-
    compact; large magnitudes now roll to that same m-scale (>=1bn shows bn).
    Input is the extracted numeric; no re-rounding of the DATA, display only."""
    if v is None:
        return "\\$0m"
    a = abs(v)
    if a >= 1e9:
        return f"\\${v / 1e9:,.2f}".rstrip("0").rstrip(".") + "bn"
    if a >= 1e6:
        return "\\$" + f"{v / 1e6:,.1f}".rstrip("0").rstrip(".") + "m"
    return f"\\${v:g}m"


def money_words(v, currency="USD"):
    """Owner format rule (2026-09-08): no scientific notation, ever; the number in
    words-scale + currency AFTER the number. e.g. 200 Million USD / 3.09 Billion USD /
    1.76 Trillion KES. Under 1 Million: thousands with separators. The currency token
    is emitted EXACTLY ONCE (dedupe rule, 2026-09-08 review round: 'KES KES' defect).
    Axis tick labels keep compact M/bn form; this helper is for figure-face text."""
    if v is None:
        return ""
    cur = (currency or "").strip() or "USD"
    a = abs(v)
    if a >= 0.5e12:
        return f"{v/1e12:,.2f} Trillion {cur}"
    if a >= 0.5e9:
        return f"{v/1e9:,.2f} Billion {cur}"
    if a >= 0.5e6:
        return f"{v/1e6:,.1f} Million {cur}"
    return f"{v:,.0f} {cur}"


def wrap_source_line(text, max_chars=95, max_lines=2):
    """Review rule (2026-09-08, both reviewers): source lines never overflow the
    6.5in print column — wrap at word boundaries. If over max_lines, the FIRST
    sentence (boilerplate lead) is what shrinks — the LAST sentence carries the
    provenance payload (money note / extra context) and is never dropped."""
    import textwrap
    text = text or ""
    wrapped = textwrap.wrap(text, width=max_chars)
    if len(wrapped) <= max_lines:
        return "\n".join(wrapped)
    parts = [s.strip() for s in text.split(". ") if s.strip()]
    if len(parts) <= 1:
        return "\n".join(textwrap.wrap(text, width=max_chars)[:max_lines])
    # Keep FIRST (source attribution) + LAST (payload); drop middle boilerplate.
    while len(parts) > 2 and len(textwrap.wrap(". ".join(parts), width=max_chars)) > max_lines:
        del parts[1]
    trimmed = ". ".join(parts)
    if not trimmed.endswith("."):
        trimmed += "."
    return "\n".join(textwrap.wrap(trimmed, width=max_chars))


def scale_mismatch(values, ratio_limit=1e4):
    """Owner rule (2026-09-08, from MLI export zeros): values captured under ONE unit
    label that span more than `ratio_limit` in magnitude are unit-mixed — plotting
    them on a shared axis fabricates zeros. Returns True when the set fails."""
    vals = [abs(v) for v in values if v is not None and v > 0]
    if len(vals) < 2:
        return False
    return max(vals) / min(vals) > ratio_limit


def footer_sources(records, note=None):
    note = note or {}
    seen = []
    for r in records:
        if r is None:
            continue
        if isinstance(r, pd.DataFrame):
            if not len(r):
                continue
            ids = r.Source_ID.tolist()
        else:
            ids = [r["Source_ID"]]
        for sid in ids:
            lbl, tier = SOURCES.get(sid, (sid, ""))
            if not lbl or "uncited" in lbl.lower():
                continue
            low = lbl.lower()
            if low.startswith(("estimated by apmd", "calculated by apmd", "derived")):
                lbl = "APMD calculations"
            if sid in note:
                lbl = f"{lbl} ({tier}, {note[sid]})"
            if lbl not in seen:
                seen.append(lbl)
    return "; ".join(seen) if seen else "see provenance sidecar"


def save_country_fig(fig, iso3, fig_id, dpi=300):
    """Save figure as SVG (web) and PNG (print, 300 DPI per VISUAL-SPEC §2.3)."""
    d = os.path.join(OUT_FIG_ROOT, iso3)
    os.makedirs(d, exist_ok=True)
    svg = os.path.join(d, fig_id + ".svg")
    png = os.path.join(d, fig_id + ".png")
    fig.savefig(svg, format="svg", bbox_inches="tight", pad_inches=0.1)
    fig.savefig(png, format="png", dpi=dpi, bbox_inches="tight", pad_inches=0.1)
    plt.close(fig)
    return svg, png


# ================================================================== FigureBuilder base class
class SkipException(Exception):
    """Raised by build_chart() when the figure must be skipped after gate check passes."""
    def __init__(self, reason, detail=None):
        self.reason = reason
        self.detail = detail


class FigureBuilder:
    """Base class for all figure builders (ARCHITECTURE-REDESIGN §4).

    Subclasses override build_chart() and provide figure metadata.
    The base class handles the pipeline:
    data load → gate → chart → style → conflicts → caption → save → sidecar.
    """

    # --- Subclass MUST override ---
    fig_id: str = ""
    title: str = ""
    chart_type: str = ""
    data_file: str = ""

    # --- Subclass MAY override ---
    figsize: tuple = (10.5, 5.6)
    title_fontsize: float = 14.0
    subtitle_fontsize: float = 8.0
    keep_left_spine: bool = True
    keep_bottom_spine: bool = True
    use_tight_layout: bool = True   # set False for heatmaps / imshow-based figures

    def __init__(self, style: StyleConfig = None, caption_mgr: CaptionManager = None):
        self.style = style or StyleConfig()
        self.caption_mgr = caption_mgr

    # --- Subclass MUST implement ---
    def load_data(self, iso3: str) -> pd.DataFrame:
        raise NotImplementedError

    def gate_check(self, df: pd.DataFrame, iso3: str, cty: str):
        """Return True if data is sufficient, or a skip reason string."""
        raise NotImplementedError

    def build_chart(self, df: pd.DataFrame, iso3: str, cty: str,
                    ax: plt.Axes) -> dict:
        """Draw chart elements on `ax`. Return a context dict."""
        raise NotImplementedError

    # --- Pipeline (called by the engine; subclasses do NOT override) ---
    def select_figsize(self, df: pd.DataFrame) -> tuple:
        """Hook: subclasses may derive a variant figsize from the data (default: class figsize)."""
        return self.figsize

    def build(self, iso3: str, cty: str) -> dict:
        """Run the full pipeline for one figure (ARCHITECTURE-REDESIGN §4.2)."""
        # 1. Load data
        df = self.load_data(iso3)

        # 2. Gate check
        gate = self.gate_check(df, iso3, cty)
        if gate is not True:
            return self._skip(iso3, cty, gate)

        # 3. Create figure with style config — subclasses may pick a variant figsize
        # from the data (hook; default returns the class figsize unchanged)
        self.figsize = self.select_figsize(df)
        fig, ax = plt.subplots(figsize=self.style.resolve_figsize(self.figsize))

        # 4. Build chart (subclass-specific)
        try:
            ctx = self.build_chart(df, iso3, cty, ax)
        except SkipException as e:
            plt.close(fig)
            return self._skip(iso3, cty, e.reason, e.detail)

        # 4a. Some builders (HerdComp) create their own multi-panel figure internally.
        #     If ctx contains '_fig', use that instead and close the auto-created one.
        multi_panel = '_fig' in ctx
        if multi_panel:
            plt.close(fig)
            fig = ctx.pop('_fig')
            axes = ctx.pop('_axes', None)

        # 5. Apply house style (skip for multi-panel — each panel styled in build_chart)
        if not multi_panel:
            style_axes(ax, keep_left=self.keep_left_spine,
                       keep_bottom=self.keep_bottom_spine)

        # 6. Compute conflicts (generalized)
        conflicts = dedup_conflicts(conflicts_in(ctx.get('conflicts_df')))

        # 7. Compose source footer (standardized template)
        # Repair (2026-09-08): prefer the builder's curated `source_list` —
        # footer_sources() re-derives attribution from RAW rows, which pulled
        # register-flagged/conflict rows' vague source labels ("Based on …")
        # into the figure face (owner rule 8: specific-source citation only).
        # Every builder supplies source_list; footer_sources stays the fallback.
        _src_list = [s for s in (ctx.get('source_list') or []) if str(s).strip()]
        source_text = self.style.compose_source_footer(
            sources=("; ".join(_src_list) if _src_list
                     else footer_sources(ctx.get('source_records', []))),
            year=ctx.get('year', ''),
            iso3=iso3, cty=cty,
            extra=ctx.get('source_extra', '')
        )

        # 8. Compose caption
        caption = self.caption_mgr.compose(
            fig_id=self.fig_id, title=self.title,
            year=ctx.get('year', ''),
            source_text=source_text
        ) if self.caption_mgr else ""

        # 9. Layout FIRST (reserve bottom space for caption/source/integrity)
        # FOOTER-RESERVATION FIX (2026-09-09): rb is now content-scaled — the
        # actual wrapped source line count (and banner lines, banner-before-
        # caption ordering below) drive the reservation, not static constants,
        # so 2-line sources get MORE room and 1-line ones stop wasting ~0.3in.
        _n_src = (source_text.count("\n") + 1) if source_text else 1
        self.style._last_figheight = self.style.resolve_figsize(self.figsize)[1]
        # BANNER-LANE WIRING (std1 (a), 2026-09-09): the wrapped integrity
        # banner is 1 line on wide canvases but 2 on narrow ones — reserve the
        # ACTUAL wrapped count. _wrap_integrity_banner is the same helper the
        # banner draws from (step 10), so the reservation can never drift
        # from the drawing.
        _n_banner = len(_wrap_integrity_banner(
            len(conflicts), self.style.resolve_figsize(self.figsize)[0])) if conflicts else 0
        # Builder-hung face content (std1 (b)): a builder may declare extra
        # footer lane inches via ctx['footer_extra_in']. When the lane hangs
        # BELOW the axes (GAPS description) its inch height depends on the
        # axes height, which depends on rb — declare a closure(fig_h_in, rb)
        # and iterate rb to fixed point (contraction ~0.155/iteration, so 4
        # iterations bound the residual far below one source line).
        def _bm(extra_in: float) -> float:
            return self.style.bottom_margin(
                has_conflicts=bool(conflicts),
                has_caption=(self.caption_mgr is not None),
                n_source_lines=_n_src, n_banner_lines=_n_banner,
                extra_footer_in=extra_in, fig_height=self.style._last_figheight)
        _extra_fn = ctx.get('footer_extra_in', None)
        _extra_fn = _extra_fn if callable(_extra_fn) else None
        _extra_val: float = 0.0
        if _extra_fn is not None:
            _rv = _extra_fn(self.style._last_figheight, 0.10)
            _extra_val = _rv if isinstance(_rv, (int, float)) else 0.0
            for _ in range(4):
                rb = _bm(_extra_val)
                _rv = _extra_fn(self.style._last_figheight, rb)
                _extra_val = _rv if isinstance(_rv, (int, float)) else 0.0
            rb = _bm(_extra_val)
        elif isinstance(ctx.get('footer_extra_in', None), (int, float)):
            _raw = ctx.get('footer_extra_in', None)
            _extra_val = _raw if isinstance(_raw, (int, float)) else 0.0
            rb = _bm(_extra_val)
        else:
            rb = _bm(0.0)
        # TITLE-LANE (std1 (c), 2026-09-09): builders that draw a fig-level
        # suptitle+subtitle stack (HerdComp pattern) declare the stack height in
        # inches via ctx['title_lane_in']; the pipeline converts to a figure
        # fraction and extends the layout rect's top edge so tight_layout /
        # subplots_adjust never lets the axes top collide with the lanes. The
        # builder's suptitle anchors at y=1.02 with va="top" — the fraction
        # below guarantees 1.02 - (su + sub lanes) stays clear of the axes top.
        _lane_decl = ctx.get('title_lane_in', None)
        _lane_in: float = 0.0
        if isinstance(_lane_decl, (int, float)):
            _lane_in = _lane_decl if isinstance(_lane_decl, (int, float)) else 0.0
        _lane_frac = _lane_in / max(self.style._last_figheight, 1e-6)
        if self.use_tight_layout:
            # Reserve left/right margins too (needed for heatmap tick labels, etc.)
            plt.tight_layout(rect=[0.03, rb, 0.97, 0.98 - _lane_frac])
        else:
            # For imshow/heatmap figures, just set the bottom margin via subplots_adjust
            fig.subplots_adjust(bottom=rb, top=0.98 - _lane_frac, left=0.03, right=0.97)

        # 10. Add integrity footer BEFORE the caption so place() can MEASURE both
        # footer lanes (source + banner) from rendered extents — see place().
        add_integrity_footer(fig, conflicts, bottom_margin=rb, source_text=source_text)

        # 11. Place caption and source line WITHIN the reserved bottom margin
        if self.caption_mgr:
            # Use the standardized compose_source_footer() for the figure-face source line
            self.caption_mgr.place(fig, caption, source_text,
                                    has_conflicts=bool(conflicts),
                                    bottom_margin=rb)

        # 12. Save (300 DPI for print per VISUAL-SPEC §2.3)
        svg, png = save_country_fig(fig, iso3, self.fig_id, dpi=self.style.dpi)

        # 13. Write sidecar
        caption_meta = self.caption_mgr.metadata(self.fig_id) if self.caption_mgr else None
        if caption_meta is not None:
            # Review-fix audit trail: the composed face source line (which now
            # carries the money note / extra context) is recorded verbatim.
            caption_meta["source_line"] = source_text
        rendering_meta = [
            f"**Generated:** {TODAY} by `design/figures/build_country_figures.py`",
            f"**Style target:** {self.style.target} ({self.style.dpi} dpi)",
            f"**Figure dimensions:** {self.figsize[0]}″ × {self.figsize[1]}″",
        ]
        sidecar_path = SidecarWriter.write(
            iso3, cty, self.fig_id, self.title, self.chart_type,
            ctx.get('sidecar_rows', []), ctx.get('source_list', []),
            ctx.get('design_notes', []),
            ctx.get('integrity', []) + conflict_integrity_lines(conflicts),
            ctx.get('validation', []),
            caption_meta=caption_meta,
            rendering_meta=rendering_meta,
        )

        # 14. Update caption state
        if self.caption_mgr:
            self.caption_mgr.update_figure_status(self.fig_id, "built", caption)
            self.caption_mgr.save_state()

        # 15. Return result
        return {
            "fig_id": self.fig_id, "status": "built",
            "svg": svg, "png": png, "sidecar": sidecar_path,
            "caption": caption, "n_conflicts": len(conflicts),
            **(ctx.get('extra', {}))
        }

    def _skip(self, iso3, cty, reason, detail=None):
        SidecarWriter.write_skip(iso3, cty, self.fig_id, self.title, reason, detail)
        # Remove stale figures
        for ext in (".svg", ".png"):
            stale = os.path.join(OUT_FIG_ROOT, iso3, self.fig_id + ext)
            if os.path.exists(stale):
                os.remove(stale)
        # Update caption state
        if self.caption_mgr:
            skip_caption = self.caption_mgr.compose(
                self.fig_id, self.title, status="skipped", skip_reason=reason
            )
            self.caption_mgr.update_figure_status(self.fig_id, "skipped", skip_caption)
            self.caption_mgr.save_state()
        return {"fig_id": self.fig_id, "status": "skipped", "reason": reason}


# ================================================================== FIG-GDP
class GDPBuilder(FigureBuilder):
    fig_id = "FIG-GDP"
    title = "Livestock's contribution to the economy"
    chart_type = ("Horizontal bars on a single consistent base (% of national GDP); "
                  "ranges shown for APMD-estimated components; different-base and "
                  "absolute figures shown as annotations.")
    data_file = "DATA_GDP.csv"
    figsize = (6.5, 3.9)  # review round 2026-09-08: two-bar canvas (3.2 was too short)
    keep_left_spine = False

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        g = df[df.Country_Code == iso3]
        if g.empty:
            return "no DATA_GDP rows for this country"
        return True

    def build_chart(self, df, iso3, cty, ax):
        g = df[df.Country_Code == iso3]
        mf = _load_manifest(self.fig_id, iso3)

        def vals(code):
            return g[g.Indicator_Code == code]

        def one(code):
            # STEP 2d: plotted row = the manifest pin for this slot (no ranking).
            return _pin_pick(g, mf, code)

        ag   = one("AG_PCT_GDP")
        lvst = one("LVST_PCT_GDP")
        # Owner rule (2026-09-08 repair): the pastoral/non-pastoral BAND is an
        # APMD-estimated component (CALC-C5/CALC-A10). Author-stated rows for the
        # same indicator — even when register-flagged as a conflict record — are
        # NEVER absorbed into the band: they carry a different provenance and per
        # AGENTS.md owner rule 2/4 a conflict row is a record, not a plotted value.
        # They remain fully disclosed via the sidecar rows + conflict layer below.
        def _apmd_estimated(recs):
            return recs[
                recs.Data_Quality.astype(str).str.strip().str.lower()
                .isin(("apmd_estimated", "apmd_calculated", "calculated", "estimated"))]
        pas  = _pin_filter(_apmd_estimated(vals("PASTORAL_PCT_GDP")), mf,
                           "PASTORAL_PCT_GDP:est")
        npas = _pin_filter(_apmd_estimated(vals("NONPAST_PCT_GDP")), mf,
                           "NONPAST_PCT_GDP:est")
        agg  = one("LVST_PCT_AGGDP")

        if lvst is None or ag is None:
            raise SkipException(
                "missing Livestock %GDP and/or Agriculture %GDP (cannot draw the shared-base bars)")

        gdp_nat = one("GDP_NATIONAL")
        lvst_abs = one("LVST_GDP_ABS")
        # Currency for figure-face money annotations: prefer the livestock-GDP row's
        # own currency (the plotted value's unit), fall back to the national row's.
        cur = "USD"
        if lvst_abs is not None and str(lvst_abs.get("Currency") or "").strip():
            cur = str(lvst_abs["Currency"]).strip()
        elif gdp_nat is not None and str(gdp_nat.get("Currency") or "").strip():
            cur = str(gdp_nat["Currency"]).strip()

        ag_v   = fnum(ag["Value"])
        lvst_v = fnum(lvst["Value"])
        pas_lo, pas_hi = (min(fnum(v) for v in pas.Value), max(fnum(v) for v in pas.Value)) if len(pas) else (None, None)
        npas_lo, npas_hi = (min(fnum(v) for v in npas.Value), max(fnum(v) for v in npas.Value)) if len(npas) else (None, None)
        year = lvst["Year"]

        # Component-consistency gate
        _tol = max(0.3, 0.03 * lvst_v) if lvst_v else 0.3
        comp_rescaled = comp_suppressed = False
        comp_note = None
        implied = None
        if pas_lo is not None and npas_lo is not None:
            implied = pas_lo + npas_hi
            if lvst_v and implied and abs(implied - lvst_v) > _tol:
                f = lvst_v / implied
                pas_lo, pas_hi = round(pas_lo * f, 1), round(pas_hi * f, 1)
                npas_lo, npas_hi = round(npas_lo * f, 1), round(npas_hi * f, 1)
                comp_rescaled = True
                comp_note = (f"Pastoral / non-pastoral split rescaled to the updated livestock "
                             f"share ({lvst_v:g}%); APMD master-CSV rows pending recomputation.")
        elif pas_lo is not None and npas_lo is None:
            if lvst_v and pas_hi > lvst_v + _tol:
                pas_lo = pas_hi = None
                comp_suppressed = True
                comp_note = ("Pastoral share exceeds the livestock total (pastoral ⊆ livestock) "
                             "and is register-flagged — split omitted pending recomputation.")
        elif npas_lo is not None and pas_lo is None:
            if lvst_v and npas_hi > lvst_v + _tol:
                npas_lo = npas_hi = None
                comp_suppressed = True
                comp_note = ("Non-pastoral share exceeds the livestock total and is register-"
                             "flagged — split omitted pending recomputation.")

        def is_fl(rec):
            return rec is not None and _flag_of(rec) in FLAG_SET

        # ── S8 COMPOSITION RE-ENCODE (owner ruling 2026-09-08, K3 design) ──────────
        # Livestock is part OF agriculture and part OF the total — containment, not
        # juxtaposition. Two aligned 100%-stacked horizontal bars:
        #   Bar A (national GDP as 100% base): Rest of economy | Other agriculture | Livestock
        #     (livestock segment split pastoral/non-pastoral as an overlaid range band)
        #   Bar B (agricultural GDP as 100% base): Other agriculture | Livestock
        # The %-of-agricultural-GDP figure becomes Bar B instead of a detached annotation.
        _has_split = (pas_lo is not None) or (npas_lo is not None)
        _agg_v = fnum(agg["Value"]) if (agg is not None and _flag_of(agg) not in FLAG_SET) else None

        barA_y, barB_y = 1.0, 0.0
        BH = 0.52
        # Bar A segments (share of national GDP): livestock first (dark), then other-ag,
        # then rest of economy fills to 100.
        rest_of_econ = max(0.0, 100.0 - (ag_v or 0))
        other_ag = max(0.0, (ag_v or 0) - (lvst_v or 0))
        segA = [("Livestock", lvst_v or 0, AU_GREEN),
                ("Other agriculture", other_ag, AU_GOLD),
                ("Rest of the economy", rest_of_econ, AU_GREY_LIGHT)]
        # Bar B segments (share of agricultural GDP)
        agg_base = _agg_v if _agg_v else 100.0
        lvst_of_ag = (lvst_v / ag_v * 100.0) if (ag_v and lvst_v is not None) else None
        segB = [("Livestock", lvst_of_ag, AU_GREEN),
                ("Other agriculture", (100.0 - lvst_of_ag) if lvst_of_ag is not None else 100.0, AU_GOLD)]

        ax.xaxis.grid(True, linestyle="-", color=GRID_COLOR, linewidth=0.6, zorder=0)
        ax.set_axisbelow(True)

        _small_lane = [0]  # per-bar counter: small labels stack upward, never collide

        def seg_label(x0, x1, y, label, value_txt, color, small=False):
            """In-segment label only when the segment is wide enough FOR THE TEXT —
            width must cover the rendered string (~0.9 units/char at 9pt on a 100-unit
            axis), else the label rides above the bar on a stacked lane (never inside
            a segment it would spill out of)."""
            width = x1 - x0
            need = 0.95 * (len(label) + len(value_txt))  # approx text width in axis units
            cx = (x0 + x1) / 2
            if width >= max(12, need):
                ax.text(cx, y, f"{label}\n{value_txt}", ha="center", va="center",
                        fontsize=(9 if small else 10), color=ink_on(color),
                        fontweight="bold", zorder=5)
            elif width >= 4:
                lane = _small_lane[0]
                _small_lane[0] += 1
                ax.text(cx, y + BH * 0.72 + lane * 0.17, f"{label} {value_txt}",
                        ha="center", va="bottom",
                        fontsize=8.6, color=FG_SECONDARY, fontweight="bold", zorder=5)
            else:
                lane = _small_lane[0]
                _small_lane[0] += 1
                ax.plot([x1, x1 + 1.2], [y, y + 0.44 + lane * 0.17],
                        color=FG_TERTIARY, linewidth=0.8, zorder=4)
                ax.text(x1 + 1.35, y + 0.44 + lane * 0.17, f"{label} {value_txt}",
                        ha="left", va="bottom",
                        fontsize=8.6, color=FG_SECONDARY, zorder=5)

        # ── Bar A: national GDP composition ──
        _small_lane[0] = 0
        _x = 0.0
        for lbl, v, col in segA:
            if v <= 0:
                continue
            ax.barh(barA_y, v, left=_x, height=BH, color=col, edgecolor=WHITE, linewidth=1.5, zorder=3)
            _fl_txt = " ‡" if (lbl == "Livestock" and is_fl(lvst)) else ""
            seg_label(_x, _x + v, barA_y, lbl, f"{v:.1f}%" + _fl_txt, col,
                      small=(lbl != "Livestock"))
            # Pastoral/non-pastoral range band inside the livestock segment. Review
            # fix (2026-09-08): band drawn FULL-HEIGHT inside the segment (was a
            # thin strip that vanished behind the segment colour) so the hatched
            # range is actually visible at print size.
            if lbl == "Livestock" and pas_lo is not None and npas_lo is not None:
                band_lo, band_hi = min(pas_lo, pas_hi), max(pas_lo, pas_hi)
                ax.barh(barA_y, band_hi - band_lo, left=band_lo, height=BH,
                        color="none", alpha=0.95, hatch="////",
                        edgecolor=AU_GREEN_DARK, linewidth=0, zorder=4)
                ax.plot([band_lo, band_lo], [barA_y - BH * 0.5, barA_y + BH * 0.5],
                        color=AU_GREEN_DARK, linewidth=1.4, zorder=5)
                ax.plot([band_hi, band_hi], [barA_y - BH * 0.5, barA_y + BH * 0.5],
                        color=AU_GREEN_DARK, linewidth=1.4, zorder=5)
            _x += v

        # ── Bar B: agricultural GDP composition ──
        _small_lane[0] = 0
        _x = 0.0
        for lbl, v, col in segB:
            if v is None or v <= 0:
                continue
            ax.barh(barB_y, v, left=_x, height=BH, color=col, edgecolor=WHITE, linewidth=1.5, zorder=3)
            seg_label(_x, _x + v, barB_y, lbl, f"{v:.1f}%", col, small=(lbl != "Livestock"))
            _x += v

        # Bar row labels (owner ruling 2026-09-09): single-line, standard-size
        # (matches HERD-COMP species-label style), vertically centered per bar.
        # '= 100' suffix dropped — the 0–100% axis already conveys the base.
        ax.text(-1.2, barA_y, "National GDP", ha="right", va="center",
                fontsize=9, color=FG_PRIMARY, fontweight="bold")
        ax.text(-1.2, barB_y, "Agricultural GDP", ha="right", va="center",
                fontsize=9, color=FG_PRIMARY, fontweight="bold")
        # Owner ruling 2026-09-09: no connector line and no repeated % annotation —
        # the livestock share of agricultural GDP is already labeled inside Bar B
        # under the 'Agricultural GDP' row label.

        ax.set_xlim(-13, 103)
        ax.set_ylim(-0.75, 1.75)
        ax.set_yticks([])
        xt = [0, 20, 40, 60, 80, 100]
        ax.set_xticks(xt)
        ax.set_xticklabels([("0%" if t == 0 else f"{t}%") for t in xt],
                           fontsize=9.5, color=FG_SECONDARY)
        ax.set_xlabel("Share (%)", fontsize=10, color=FG_SECONDARY)
        for s in ("left", "top", "right"):
            ax.spines[s].set_visible(False)

        ax.set_title(f"Livestock's Contribution to {cty}'s Economy",
                     fontsize=16.5, fontweight="bold", color=AU_GREEN_DARK, pad=30, loc="left")
        _has_comp = (pas_lo is not None) or (npas_lo is not None)
        if _has_comp and comp_rescaled:
            _sub2 = (" Pastoral share shown as a hatched band (estimated, rescaled to the updated "
                     "total).")
        elif _has_comp:
            _sub2 = " Pastoral share shown as a hatched band (estimated by APMD)."
        elif comp_note:
            _sub2 = " " + comp_note
        else:
            _sub2 = ""
        _gdp_sub = f"Composition of national and agricultural GDP, {year}.{_sub2}"
        # SUBTITLE-WRAP FIX (std1 (d), 2026-09-09): the subtitle was ONE unwrapped
        # ax.text line — its tight-bbox artist widened the saved PNG on long-text
        # countries (KEN 2547px canvas + 3.04in axes) — the same defect class as
        # the HerdComp subtitle. OWNER RULE: face text wraps to the authored
        # figure width, never widens the image (fig-width-derived budget, 0.55
        # em/char at 9pt — same standard as HerdComp). Width from the builder's
        # resolved figsize — the exact width plt.subplots used in build().
        _gdp_lines = _wrap_subtitle_lines(
            _gdp_sub, self.style.resolve_figsize(self.figsize)[0], 9.0)
        ax.text(0, 1.055, "\n".join(_gdp_lines),
                transform=ax.transAxes, va="bottom", ha="left", fontsize=9, color=FG_SECONDARY)

        notes_line = []
        gdp_note_suppressed = None  # set when the absolute-GDP pair fails reconciliation
        ctx_money_note = None       # set when the absolute-GDP pair reconciles (ships in source lane)

        def money(v, c=None):
            """Owner format rule: natural words-scale, currency after (200 Million USD)."""
            return money_words(v, c or cur)

        if agg is not None and _flag_of(agg) not in FLAG_SET:
            # The %-of-agricultural-GDP figure is now Bar B in the chart itself —
            # the detached annotation would duplicate it (and disagree when the
            # extracted row uses a different denominator basis than the plotted
            # shares). Kept in the sidecar for provenance, off the figure face.
            pass
        if (gdp_nat is not None and lvst_abs is not None
                and _flag_of(gdp_nat) not in FLAG_SET and _flag_of(lvst_abs) not in FLAG_SET):
            gn, la = fnum(gdp_nat['Value']), fnum(lvst_abs['Value'])
            if gn and la:
                # Reconciliation gate: the absolute pair is only shown when the
                # livestock share it implies is consistent with the plotted
                # LVST_PCT_GDP value (±1.0pp). A mixed-era pair (e.g. old
                # billions-scaled GDP with amended raw USD livestock GDP)
                # implies a nonsense share and is omitted from the face
                # (rows remain in the sidecar).
                implied_pct = la / gn * 100 if gn else None
                if implied_pct is not None and abs((implied_pct or 0) - (lvst_v or 0)) <= 1.0:
                    # money_words() appends the currency token exactly once; the note
                    # ships in the SOURCE LANE via source_extra (review fix: rendering
                    # it as a floating ax.text collided with the x-axis title).
                    ctx_money_note = (
                        f"Livestock GDP ≈ {money(la, cur)} of {money(gn, cur)} national GDP")
                else:
                    gdp_note_suppressed = (
                        f"Absolute-GDP annotation suppressed: the extracted pair (livestock "
                        f"{money(la, cur)}, national {money(gn, cur)}) implies a livestock share of "
                        f"{implied_pct:.1f}%, inconsistent with the plotted {lvst_v:g}% "
                        f"(mixed-era or unit-mismatched rows). FOLLOW-UP: reconcile the "
                        f"GDP_NATIONAL / LVST_GDP_ABS pair at source.")
        if notes_line:
            # Review fix (2026-09-08): residual notes render in the SOURCE LANE
            # (appended to source_extra at ctx build), never as a floating ax.text
            # that collides with the axis title.
            pass

        ax.tick_params(left=False)

        # Conflicts
        covered = g[g.Indicator_Code.isin(["AG_PCT_GDP", "LVST_PCT_GDP", "PASTORAL_PCT_GDP",
                                           "NONPAST_PCT_GDP", "LVST_PCT_AGGDP", "GDP_NATIONAL",
                                           "LVST_GDP_ABS", "AG_GDP_ABS"])]

        # Sidecar rows
        def row(rec, prov):
            return dict(ind=rec["Indicator_Name"], code=rec["Indicator_Code"], val=rec["Value"],
                        unit=rec["Unit"], yr=rec["Year"], src=src_str(rec["Source_ID"]),
                        prov=prov, flag=rec["Flag_Reason"])
        rows = [row(ag, "Reported (authoritative pick)"),
                row(lvst, "Reported (authoritative pick)")]
        _pas_prov = ("APMD_Estimated (CALC-C5; rescaled in figure to updated total)" if comp_rescaled
                     else "APMD_Estimated (CALC-C5; SUPPRESSED — inconsistent with total)" if comp_suppressed
                     else "APMD_Estimated (CALC-C5)")
        _npas_prov = ("APMD_Estimated (CALC-A10; rescaled in figure to updated total)" if comp_rescaled
                      else "APMD_Estimated (CALC-A10)")
        for _, r in pas.iterrows():
            rows.append(row(r, _pas_prov))
        for _, r in npas.iterrows():
            rows.append(row(r, _npas_prov))
        # Owner rule (2026-09-08 repair): author-stated rows for the pastoral /
        # non-pastoral indicators stay in the sidecar as disclosed provenance —
        # flagged rows surface again via the conflict layer below.
        for _code, _prov in (("PASTORAL_PCT_GDP", "Reported (author-stated; NOT plotted — "
                              "conflicts with the APMD-estimated band, kept as a record)"),
                             ("NONPAST_PCT_GDP", "Reported (author-stated; NOT plotted)")):
            _nonest = vals(_code)
            if len(_nonest):
                _nonest = _nonest[~_nonest["Data_Quality"].astype(str).str.strip().str.lower()
                                  .isin(("apmd_estimated", "apmd_calculated", "calculated", "estimated"))]
                # STEP 2d: author-stated disclosure rows = the :disc pins.
                _nonest = _nonest[_nonest["Brief_Ref"].isin(
                    [p.get("data_point_id", "") for p in mf.get("pins", [])
                     if p.get("slot") == _code + ":disc"])]
                for _, r in _nonest.iterrows():
                    rows.append(row(r, _prov))
        if agg is not None:
            rows.append(row(agg, "Reported (different base: % of Ag GDP)"))
        if gdp_nat is not None:
            rows.append(row(gdp_nat, "National GDP (context)"))
        if lvst_abs is not None:
            rows.append(row(lvst_abs, "Livestock GDP absolute (context)"))

        validation = [
            f"Livestock % of GDP plotted = {lvst_v:g}%  (source value: {lvst['Value']}%)  ✓ verbatim.",
            f"Agriculture % of GDP plotted = {ag_v:g}%  (source value: {ag['Value']}%)  ✓ verbatim.",
        ]
        if pas_lo is not None and npas_lo is not None:
            if comp_rescaled:
                validation.append(
                    f"Component reconciliation: the pastoral/non-pastoral split was derived on the OLD "
                    f"livestock total ({implied:g}%); rescaled to the current {lvst_v:g}% "
                    f"(× {lvst_v/implied:.3f}) → pastoral {pas_lo:g}–{pas_hi:g}% + non-pastoral "
                    f"{npas_lo:g}–{npas_hi:g}% now sum to {lvst_v:g}%  ✓.")
            else:
                validation.append(
                    f"Internal consistency: APMD-estimated pastoral band ({pas_lo:g}–{pas_hi:g}%) "
                    f"+ APMD-estimated non-pastoral band ({npas_lo:g}–{npas_hi:g}%) span the "
                    f"livestock total {lvst_v:g}% (bands are anti-correlated bounds of one "
                    f"total, not additive parts — they are not summed).")
        elif comp_suppressed:
            validation.append(
                f"Component suppressed: the derived split was inconsistent with the livestock total "
                f"{lvst_v:g}% (pastoral ⊆ livestock ⇒ pastoral ≤ {lvst_v:g}%); the register-flagged value "
                f"is disclosed in the provenance rows / conflict layer, not drawn as a clean bar.")

        integrity = ([
            "All bars share ONE base (% of national GDP); the %-of-AGRICULTURAL-GDP figure is a "
            "different base and is shown as an annotation, not a bar (dataviz: one axis).",
            "Authoritative selection: for each share the clean, cited row is plotted (prefer non-flagged, "
            "then cited); where the only available row is register-flagged it is still plotted and marked "
            "with '‡' in maroon on the figure face.",
        ] + ([gdp_note_suppressed] if gdp_note_suppressed else []) + ([] if not comp_rescaled else [
            f"Component-consistency gate: the pastoral/non-pastoral split (APMD-derived, CALC-C5/A10) was "
            f"derived on a livestock total of {implied:g}% that has since been re-extracted to {lvst_v:g}%. "
            f"For this figure the split is rescaled to the current total preserving its proportion "
            f"(pastoral-share × current Livestock%GDP) so the parts sum to {lvst_v:g}%. FOLLOW-UP: the APMD "
            f"master-CSV rows (PASTORAL_PCT_GDP / NONPAST_PCT_GDP) must be recomputed at source — this "
            f"engine reconciliation does NOT mutate the master CSV."
        ]) + ([] if not comp_suppressed else [
            f"Component-consistency gate: the derived pastoral share exceeded the livestock total "
            f"({lvst_v:g}%), which is impossible (pastoral ⊆ livestock). The register-flagged value is "
            f"disclosed via the conflict layer + provenance rows but is NOT drawn as a clean component bar. "
            f"FOLLOW-UP: recompute or withdraw the APMD master-CSV pastoral-%GDP row at source."
        ]))

        return {
            "conflicts_df": covered,
            "source_records": [ag, lvst, pas, npas, agg, gdp_nat, lvst_abs],
            "year": year,
            "sidecar_rows": rows,
            "source_extra": (f"{ctx_money_note}." if ctx_money_note else ""),
            "source_list": [src_str(ag["Source_ID"]), src_str(lvst["Source_ID"]),
                            "APMD calculations CALC-C5 (pastoral) / CALC-A10 (non-pastoral)"],
            "design_notes": [
                "Colours: Agriculture in gold (parent sector), livestock total in AU green, components in green tints.",
                "APMD-estimated components drawn as a solid bar to the low value + a hatched translucent "
                "extension to the high value with a whisker cap; labelled as a range and tagged '(estimated by "
                "APMD)' (with a rescale note when the split is reconciled to an updated livestock total)."
            ],
            "integrity": integrity,
            "validation": validation,
            "extra": {"n_rows": len(rows)},
        }


# ================================================================== FIG-POP-TREND
class PopTrendBuilder(FigureBuilder):
    fig_id = "FIG-POP-TREND"
    title = "Livestock population by species, over time"
    chart_type = "Multi-series line chart (4 species x years); reported points solid, projected points hollow with a dashed segment."
    data_file = "DATA_Livestock_Pop.csv"
    figsize = (6.5, 3.7)  # minimax recipe: print-column trend canvas

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        p = df[df.Country_Code == iso3]
        if p.empty:
            return "no DATA_Livestock_Pop rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        p = df[df.Country_Code == iso3]
        tot = p[(p.Unit == "head") & (p.Species_Name.isin(SPECIES_ORDER)) &
                (p.Breed == "") & (p.Production_System == "") & (p.Admin_Zone == "")].copy()
        tot["yr"] = tot.Year.apply(fnum)
        tot["val"] = tot.Value.apply(fnum)
        tot = tot[tot.val.notna()]
        if tot.empty:
            raise SkipException( "no clean species-total head rows")

        cov = (tot.groupby("Source_ID")["yr"].nunique().sort_values(ascending=False))
        best_src = cov.index[0]
        if cov.iloc[0] < 2:
            raise SkipException(
                f"best source ({best_src}) has <2 distinct years — no time series to draw",
                "A single-year snapshot exists (see FIG-HERD-COMP) but no multi-year population "
                "series is present for this country.")
        ser = tot[tot.Source_ID == best_src].copy()
        ser = (ser.sort_values(["Species_Name", "yr", "Flag_Reason"])
                  .drop_duplicates(subset=["Species_Name", "yr"], keep="first"))
        # STEP 2d: plotted series points = manifest pins (no ranking).
        ser = _pin_filter(ser, _load_manifest(self.fig_id, iso3), "series")
        slabel, stier = src(best_src)

        years = sorted(ser.yr.dropna().unique())
        ax.yaxis.grid(True, linestyle="-", color=GRID_COLOR, linewidth=0.6, zorder=0)
        ax.set_axisbelow(True)

        plotted = {}
        proj_years = set()
        proj_spans = []  # (species, first_proj_year) for the shaded projection band
        for sp in SPECIES_ORDER:
            d = ser[ser.Species_Name == sp].sort_values("yr")
            if d.empty:
                continue
            xs = d.yr.tolist()
            ys = d.val.tolist()
            col = SPECIES_COLORS[sp]
            mk = SPECIES_MARKERS[sp]
            is_proj = [dq.lower().startswith("projected") for dq in d.Data_Quality.tolist()]
            # minimax recipe: projection band (species-color, alpha 0.13) spanning
            # the projected year range; dashed 1.4pt line into projected points;
            # hollow markers = white face + species edge.
            if any(is_proj):
                pj_x = [x for x, pj in zip(xs, is_proj) if pj]
                if pj_x:
                    ax.axvspan(min(pj_x), max(xs[-1], max(pj_x)),
                               color=col, alpha=0.10, linewidth=0, zorder=1)
                    proj_spans.append((min(pj_x), max(xs[-1], max(pj_x))))
            for k in range(len(xs) - 1):
                style = "--" if (is_proj[k + 1]) else "-"
                lw = 1.4 if (is_proj[k + 1]) else 1.6
                ax.plot(xs[k:k + 2], ys[k:k + 2], style, color=col, linewidth=lw, zorder=3,
                        solid_capstyle="round")
            for xi, yi, pj in zip(xs, ys, is_proj):
                ax.scatter(xi, yi, s=(36 if pj else 44), marker=mk, zorder=4,
                           facecolor=(WHITE if pj else col), edgecolor=col, linewidth=1.2)
                if pj:
                    proj_years.add(int(xi))
            # end-label: two stacked lines (name over value) just past the right spine
            ax.text(1.008, ys[-1], SPECIES_LABELS[sp], transform=ax.get_yaxis_transform(),
                    va="bottom", ha="left", fontsize=8, color=col, zorder=5)
            ax.text(1.008, ys[-1], f"{ys[-1]/1e6:.1f}M", transform=ax.get_yaxis_transform(),
                    va="top", ha="left", fontsize=8.5, color=col, fontweight="bold", zorder=5)
            plotted[sp] = list(zip([int(x) for x in xs], [d.Value.tolist()[i] for i in range(len(xs))]))

        if not plotted:
            raise SkipException( "no species series to plot after selection")

        ax.set_xlim(min(years) - 0.15, max(years) + 0.35)
        ax.set_xticks(years)
        ax.set_xticklabels([str(int(y)) for y in years], fontsize=7.5, color=FG_SECONDARY)
        ax.set_ylim(0, None)
        ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v/1e6:.1f}M"))
        ax.set_ylabel("Head (millions)", fontsize=8, color=FG_SECONDARY)
        ax.tick_params(axis="y", labelsize=7.5, colors=FG_SECONDARY)

        ax.set_title(f"{cty} Livestock Population by Species, {int(min(years))}–{int(max(years))}",
                     fontsize=16.5, fontweight="bold", color=AU_GREEN_DARK, pad=30, loc="left")
        _pj_sorted = sorted(proj_years)
        if _pj_sorted:
            # Projection range disclosure: contiguous range reads clean; scattered
            # projected years get an explicit list (never imply a false span).
            if _pj_sorted == list(range(_pj_sorted[0], _pj_sorted[-1] + 1)):
                _pj_txt = (str(_pj_sorted[0]) if len(_pj_sorted) == 1
                           else f"{_pj_sorted[0]}–{_pj_sorted[-1]}")
            else:
                _pj_txt = ", ".join(str(y) for y in _pj_sorted)
            proj_note = f" Dashed + shaded = {_pj_txt} projected."
        else:
            proj_note = ""
        # SUBTITLE-WRAP FIX (std1 (d), 2026-09-09): one unwrapped ax.text line —
        # same defect class as the HerdComp/GDP subtitle (tight-bbox artist
        # widened the saved PNG on long-text countries). OWNER RULE: face text
        # wraps to the authored figure width, never widens the image.
        _pop_sub = (f"Head of cattle, sheep, goats and camels. Source: {slabel} ({stier})."
                    + proj_note)
        _pop_lines = _wrap_subtitle_lines(
            _pop_sub, self.style.resolve_figsize(self.figsize)[0], 9.0)
        ax.text(0, 1.045, "\n".join(_pop_lines),
                transform=ax.transAxes, va="bottom", ha="left", fontsize=9, color=FG_SECONDARY)
        # One "Projected" tag inside the band zone (top-left of first band) — recipe rule.
        # Bands that span nearly the whole x-range would put the tag over data; the
        # subtitle already discloses the projected range, so the tag only renders when
        # the band covers <60% of the x-range.
        if proj_spans:
            _x0, _x1 = ax.get_xlim()
            _first = min(s[0] for s in proj_spans)
            if (_first - _x0) / (_x1 - _x0) < 0.60:
                ax.text(_first, 0.97, "Projected",
                        transform=ax.get_xaxis_transform(), ha="left", va="top",
                        fontsize=7, color=FG_TERTIARY, fontstyle="italic", zorder=5)
        # LEGEND REMOVED (minimax): end-of-line labels carry species + value already

        rows = []
        for sp in SPECIES_ORDER:
            d = ser[ser.Species_Name == sp].sort_values("yr")
            for _, r in d.iterrows():
                rows.append(dict(ind=f"Total {sp.capitalize()} Population", code="(species total)",
                                 val=r["Value"], unit=r["Unit"], yr=r["Year"],
                                 src=f"{slabel} ({stier})", prov=r["Data_Quality"],
                                 flag=r["Flag_Reason"]))
        validation = []
        for sp in SPECIES_ORDER:
            pts = plotted.get(sp, [])
            if pts:
                chk = "; ".join(f"{yr}:{val}" for yr, val in pts)
                validation.append(f"{SPECIES_LABELS[sp]} plotted (year:verbatim head) = {chk}  ✓ verbatim from {best_src}.")
        integrity = ([
            "The authoritative multi-year series (the source covering the most distinct years) is plotted; "
            "single-year snapshots and lower-tier restatements are NOT plotted.",
            "Distinct marker shape per species (o/s/^/D) + direct line-end labels are the secondary encoding "
            "required because the fixed AU SPECIES palette's sheep-green↔goats-gold pair sits in the CVD 6–8 "
            "floor band; normal-vision separation passes (ΔE 18.3). Single y-axis (head, millions).",
        ])

        return {
            "conflicts_df": tot,
            "source_records": [ser],
            "year": f"{int(min(years))}–{int(max(years))}",
            "source_extra": (f"({min(proj_years)} projected)" if proj_years else ""),
            "sidecar_rows": rows,
            "source_list": [f"{slabel} — {cty} ({stier}), the multi-year species series."],
            "design_notes": [
                "Colours are the house SPECIES_COLORS (entity-fixed, never cycled).",
                "Projected points (Data_Quality 'projected…') drawn hollow with a dashed segment."
            ],
            "integrity": integrity,
            "validation": validation,
            "extra": {"series_source": f"{slabel} ({stier})",
                      "n_points": sum(len(v) for v in plotted.values())},
        }


# ================================================================== FIG-HERD-COMP
class HerdCompBuilder(FigureBuilder):
    fig_id = "FIG-HERD-COMP"
    title = "Herd composition, latest year (by head)"
    chart_type = ("Single horizontal-bar panel for the latest year: herd by head; "
                  "species share (%) annotated on each bar.")
    data_file = "DATA_Livestock_Pop.csv"
    figsize = (6.5, 4.4)  # minimax recipe: single panel at print-column width
    keep_left_spine = False

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        p = df[df.Country_Code == iso3]
        if p.empty:
            return "no DATA_Livestock_Pop rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        # This builder uses its own subplots (2-panel), so we handle fig/axes internally
        # and return a special context. The base class build() will still work because
        # we create the figure here and store it in ctx.
        p = df[df.Country_Code == iso3]
        tot = p[(p.Unit == "head") & (p.Species_Name.isin(SPECIES_ORDER)) &
                (p.Breed == "") & (p.Production_System == "") & (p.Admin_Zone == "")].copy()
        tot["yr"] = tot.Year.apply(fnum)
        tot["val"] = tot.Value.apply(fnum)
        tot = tot[tot.val.notna()]
        if tot.empty:
            raise SkipException( "no clean species-total head rows")

        years = sorted({int(y) for y in tot.yr.dropna().unique()})
        best_year = None
        for yv in reversed(years):
            if tot[tot.yr == yv].Species_Name.nunique() >= 2:
                best_year = yv
                break
        if best_year is None:
            raise SkipException(
                "no single year has ≥2 species totals — cannot show composition")

        sub = tot[tot.yr == best_year]
        _mf = _load_manifest(self.fig_id, iso3)
        rows_by_sp = {}
        for sp in SPECIES_ORDER:
            d = sub[sub.Species_Name == sp]
            if len(d):
                # STEP 2d: plotted species row = the manifest pin (no ranking).
                _rec = _pin_pick(d, _mf, "species:" + sp)
                if _rec is None:
                    # No pin: the baseline dropped this species (0/unreported head).
                    # Keep it only for the not-reported disclosure; fail loud if the
                    # un-pinned row actually carries a nonzero value.
                    _rec = pick(d)
                    if fnum(_rec["Value"]):
                        raise SystemExit(
                            "FAIL [guard]: figure %s/%s — species %r has nonzero rows "
                            "but no 'species:' pin in its manifest"
                            % (iso3, self.fig_id, sp))
                rows_by_sp[sp] = _rec
        if len(rows_by_sp) < 2:
            raise SkipException(
                f"only {len(rows_by_sp)} species total(s) in {best_year}")

        heads = {sp: fnum(r["Value"]) for sp, r in rows_by_sp.items()}
        # OWNER/K3 MISSING-NOT-ZERO GATE (2026-09-08): a species captured as 0 without
        # an explicit source-zero is 'not reported', not zero. Data rows carry no
        # explicit-zero marker, so ANY 0/None value here is treated as unreported:
        # the species is dropped from the bars and disclosed in the subtitle.
        _dropped = [sp for sp, v in heads.items() if not v]
        for sp in _dropped:
            del heads[sp]
        order = [sp for sp in SPECIES_ORDER if sp in heads]
        head_tot = sum(heads.values())

        fig, ax_panel = plt.subplots(1, 1, figsize=self.style.resolve_figsize(self.figsize))
        data, total, unit_lbl, panel_title = heads, head_tot, "Head", "By head count"
        ax_panel.xaxis.grid(True, linestyle="-", color=GRID_COLOR, linewidth=0.6, zorder=0)
        ax_panel.set_axisbelow(True)
        yy = np.arange(len(order))[::-1]
        for yi, sp in zip(yy, order):
            v = data[sp]
            ax_panel.barh(yi, v, height=0.70, color=SPECIES_COLORS[sp], edgecolor=WHITE,
                    linewidth=1.5, zorder=3)
            pct = (v / total * 100) if total else 0
            ax_panel.text(v + total * 0.015, yi, f"{v/1e6:.1f}M ({pct:.0f}%)", va="center", ha="left",
                    fontsize=8.5, fontweight="bold", color=FG_PRIMARY, zorder=5)
        ax_panel.set_yticks(yy)
        ax_panel.set_yticklabels([SPECIES_LABELS[sp] for sp in order], fontsize=9,
                           color=FG_PRIMARY, fontweight="bold")
        ax_panel.set_xlim(0, max(data.values()) * 1.35)
        ax_panel.xaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: f"{v/1e6:.1f}M"))
        ax_panel.tick_params(axis="x", labelsize=7.5, colors=FG_SECONDARY)
        ax_panel.set_xlabel(unit_lbl, fontsize=8.5, color=FG_SECONDARY)
        ax_panel.set_title(panel_title, fontsize=11, fontweight="bold", color=FG_PRIMARY, pad=8, loc="left")
        style_axes(ax_panel, keep_left=False)
        ax_panel.tick_params(left=False)

        # minimax recipe: suptitle 16.5pt, subtitle own line, disclosure own line
        # (never inline-appended).
        # SUBTITLE-WRAP FIX (2026-09-09): the subtitle was ONE unwrapped fig.text
        # line — its tight-bbox artist widened the saved PNG (KEN 3225px vs fleet
        # 2539px at identical fig size) and its va="bottom" growth ran the block
        # UP into the suptitle lane. OWNER RULE: subtitle text wraps to the
        # authored figure width, never widens the image. Char budget derives from
        # the real fig size (0.55 em/char at 9pt, conservative) — not one
        # hardcoded constant — so any future figsize keeps the wrap correct.
        import textwrap
        _fig_w_in, _fig_h_in = fig.get_size_inches()
        _sub_cpl = max(30, int(_fig_w_in / (9.0 / 72.0 * 0.55)))
        _sub_lines = textwrap.wrap(
            "Species head counts for the latest year; the share (%) of the total herd is "
            "annotated on each bar.",
            width=_sub_cpl)
        _sub_step = (9.0 * 1.2) / 72.0 / _fig_h_in      # fig-frac per subtitle line
        _sup_h = (16.5 * 1.2) / 72.0 / _fig_h_in        # fig-frac height of suptitle glyphs
        _sub_y = 1.02 - _sup_h - 0.006                  # top anchor BELOW suptitle glyphs
        fig.suptitle(f"{cty} Herd Composition, {best_year}", fontsize=16.5, fontweight="bold",
                     color=AU_GREEN_DARK, x=0.02, ha="left", y=1.02, va="top")
        fig.text(0.02, _sub_y, "\n".join(_sub_lines),
                 ha="left", va="top", fontsize=9, color=FG_SECONDARY)
        if _dropped:
            # Disclosure keeps its OWN line, anchored BELOW the wrapped subtitle
            # block (block height = n_lines × line step) — lanes cannot overlap
            # regardless of how many lines the wrap produces.
            fig.text(0.02, _sub_y - len(_sub_lines) * _sub_step - 0.006,
                     "Not reported — " + ", ".join(SPECIES_LABELS.get(sp, sp) for sp in _dropped)
                     + f" ({best_year}); absence is not zero.",
                     ha="left", va="top", fontsize=8, color=FG_SECONDARY, fontstyle="italic")

        srcs = "; ".join(sorted({src_str(r["Source_ID"]) for r in rows_by_sp.values()}))

        rows = []
        for sp in order:
            r = rows_by_sp[sp]
            rows.append(dict(ind=f"Total {sp.capitalize()} Population", code="(species total)",
                             val=r["Value"], unit=r["Unit"], yr=r["Year"], src=src_str(r["Source_ID"]),
                             prov=r["Data_Quality"], flag=r["Flag_Reason"]))
        validation = [f"{SPECIES_LABELS[sp]}: head {rows_by_sp[sp]['Value']} (verbatim)  ✓."
                      for sp in order]
        validation.append(f"Head total {head_tot:,.0f} across {len(order)} species.")
        integrity = ([
            "Head counts are plotted verbatim from the datahub extract — no transformation applied.",
            "Species order and colours are the fixed house SPECIES_COLORS (entity-fixed, never cycled). "
            "Single-panel chart; species share (%) annotated per bar.",
        ])

        # Store the figure in ctx so the base pipeline can use it
        return {
            "_fig": fig,
            "_axes": ax_panel,
            "conflicts_df": sub,
            "source_records": list(rows_by_sp.values()),
            "year": str(best_year),
            "sidecar_rows": rows,
            "source_list": [srcs],
            "design_notes": [
                "Percent-of-herd annotated per bar so the composition reads without a pie.",
                "Head counts plotted verbatim; percent share is of the summed species head count."
            ],
            "integrity": integrity,
            "validation": validation,
            "extra": {"year": best_year, "n_species": len(order)},
        }


# ================================================================== FIG-SYSTEM
_SYS_BUCKETS = [
    ("Intensive / commercial", ["intensive", "peri-urban", "peri urban", "commercial", "modern",
                                "feedlot", "ranch", "fenced", "exotic", "crossbred", "dairy cattle",
                                "semi-intensive"]),
    ("Agro-pastoral / mixed",  ["agro-pastoral", "agropastoral", "agro pastoral", "mixed crop",
                                "sedentary mixed", "mixed farming"]),
    ("Pastoral (mobile)",      ["pastoral", "nomad", "transhum", "mobile"]),
]


def _sys_bucket(label):
    s = (label or "").lower()
    for name, keys in _SYS_BUCKETS:
        if any(k in s for k in keys):
            return name
    return None


class SystemBuilder(FigureBuilder):
    fig_id = "FIG-SYSTEM"
    title = "Share of national herd by production system"
    chart_type = "100% horizontal stacked bar — pastoral / agro-pastoral / intensive share of the national herd."
    data_file = "DATA_Livestock_Pop.csv"
    figsize = (11.0, 3.9)
    keep_left_spine = False
    keep_bottom_spine = False

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        p = df[df.Country_Code == iso3]
        if p.empty:
            return "no DATA_Livestock_Pop rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        p = df[df.Country_Code == iso3]
        cand = p[(p.Unit == "%") & (p.Production_System != "")].copy()
        cand["val"] = cand.Value.apply(fnum)
        cand = cand[cand.val.notna() & (cand.val > 0) & (cand.val <= 100)]
        if cand.empty:
            raise SkipException(
                "no production-system SHARE (%) rows for this country",
                "Production-system data, where present, is head counts by heterogeneous "
                "typology, not a clean pastoral/agro-pastoral/intensive share of the national "
                "herd. Not fabricated into a stacked bar.")

        chosen = None
        for (sid, yr), grp in cand.groupby(["Source_ID", "Year"]):
            buckets = {}
            for _, r in grp.iterrows():
                b = _sys_bucket(r["Production_System"])
                if b is None or b in buckets:
                    continue
                buckets[b] = (r["val"], r)
            if len(buckets) >= 2:
                ssum = sum(v for v, _ in buckets.values())
                if 97 <= ssum <= 103:
                    chosen = (sid, yr, buckets, ssum)
                    break
        if chosen is None:
            raise SkipException(
                "no single source-year gives a clean pastoral/agro-pastoral/intensive herd-share "
                "decomposition summing to ~100%",
                "Candidate production-system %-rows exist but are per-species or partial shares "
                "that do not compose to a 100% national-herd split from one source; some are "
                "register-flagged. Skipped rather than fabricate a decomposition.")

        sid, yr, buckets, ssum = chosen
        slabel, stier = src(sid)
        order_sys = ["Pastoral (mobile)", "Agro-pastoral / mixed", "Intensive / commercial"]
        _mf = _load_manifest(self.fig_id, iso3)
        seg = []
        for name in order_sys:
            if name not in buckets:
                continue
            # STEP 2d: each plotted segment row must be the manifest's pin.
            _ids = [p.get("data_point_id", "") for p in _mf.get("pins", [])
                    if p.get("slot") == "segment:" + name]
            _rec = buckets[name][1]
            if not _ids or str(_rec.get("Brief_Ref", "")) not in _ids:
                raise SystemExit(
                    "FAIL [guard]: figure %s/%s — segment %r plotted row (Brief_Ref %r) "
                    "does not match its manifest pins %s"
                    % (iso3, self.fig_id, name, _rec.get("Brief_Ref", ""), _ids))
            seg.append((name, buckets[name][0], _rec))
        colors = {"Pastoral (mobile)": AU_GREEN, "Agro-pastoral / mixed": AU_GOLD,
                  "Intensive / commercial": AU_TEAL}

        sstr = src_str(sid)
        ytxt = f", {yr}" if str(yr).strip() else ""
        left = 0.0
        for name, v, _r in seg:
            ax.barh(0, v, left=left, height=0.5, color=colors[name], edgecolor=WHITE,
                    linewidth=2, zorder=3)
            if v < 12:
                ax.text(left + v / 2, 0.30, f"{name}\n{v:g}%", ha="center", va="bottom",
                        fontsize=8.3, color=FG_SECONDARY, fontweight="bold", zorder=4)
                ax.plot([left + v / 2, left + v / 2], [0.05, 0.28], color=FG_TERTIARY,
                        linewidth=0.8, zorder=4)
            else:
                ax.text(left + v / 2, 0, f"{name}\n{v:g}%", ha="center", va="center", fontsize=9.5,
                        color=ink_on(colors[name]), fontweight="bold", zorder=4)
            left += v
        ax.set_xlim(0, max(100, left))
        ax.set_ylim(-0.6, 0.6)
        ax.set_yticks([])
        ax.set_xticks([0, 25, 50, 75, 100])
        ax.set_xticklabels(["0%", "25%", "50%", "75%", "100%"], fontsize=9, color=FG_SECONDARY)
        ax.set_title(f"{cty}: Share of National Herd by Production System{ytxt}",
                     fontsize=14.5, fontweight="bold", color=AU_GREEN_DARK, pad=34, loc="left")
        ax.text(0, 1.10, f"Single source: {sstr}. Segments sum to {ssum:g}%.",
                transform=ax.transAxes, va="bottom", ha="left", fontsize=9, color=FG_SECONDARY)
        for s in ax.spines.values():
            s.set_visible(False)
        ax.tick_params(left=False)

        rows = [dict(ind=self.title, code=name, val=f"{v:g}", unit="%", yr=yr,
                     src=f"{slabel} ({stier})", prov=r["Data_Quality"], flag=r["Flag_Reason"])
                for name, v, r in seg]
        integrity = (["Built only because a single source-year composed to ~100% across ≥2 systems; "
                      "otherwise skipped (no fabrication)."])

        return {
            "conflicts_df": cand,
            "source_records": [r for _, _, r in seg],
            "year": str(yr) if str(yr).strip() else "",
            "sidecar_rows": rows,
            "source_list": [f"{slabel} ({stier})"],
            "design_notes": [
                "Free-text production-system labels classified into the three canonical systems by keyword; "
                "only a one-source, ~100% decomposition qualifies."
            ],
            "integrity": integrity,
            "validation": [f"Segments: " + ", ".join(f"{n} {v:g}%" for n, v, _ in seg) + f" → Σ {ssum:g}%."],
        }


# ================================================================== FIG-INCOME
class IncomeBuilder(FigureBuilder):
    fig_id = "FIG-INCOME"
    title = "Livestock share of pastoral household income"
    chart_type = ("100% horizontal stacked bar — livestock vs other sources' share of pastoral household income "
                  "(range whisker if sources disagree).")
    data_file = "DATA_Household.csv"
    figsize = (6.5, 2.6)          # minimax recipe: same share-bar class as EMPLOY
    keep_left_spine = False
    keep_bottom_spine = True

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        h = df[df.Country_Code == iso3]
        if h.empty:
            return "no DATA_Household rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        h = df[df.Country_Code == iso3]
        share = h[(h.Indicator_Code == "HH_INC_LVST_SHARE") & (h.Unit == "%")].copy()
        share["val"] = share.Value.apply(fnum)
        share = share[share.val.notna() & (share.val >= 0) & (share.val <= 100)]
        if share.empty:
            raise SkipException(
                "no Livestock share of pastoral household income (HH_INC_LVST_SHARE, %) rows")

        pref = share[share.Household_Type.str.contains("pastoral", case=False, na=False)]
        use = pref if len(pref) else share
        _mf = _load_manifest(self.fig_id, iso3)
        # STEP 2d: disclosed sidecar rows = the 'disclosed' pins; plotted row = the
        # 'plotted' pin (no ranking).
        use = _pin_filter(use, _mf, "disclosed")
        chosen = _pin_pick(use, _mf, "plotted")
        if chosen is None:
            raise SystemExit(
                "FAIL [guard]: figure %s/%s — no 'plotted' pin in its manifest"
                % (iso3, self.fig_id))
        vals = sorted(set(use.val.tolist()))
        lo, hi = min(vals), max(vals)
        point = fnum(chosen["Value"]) or 0.0

        # minimax delta recipe (2026-09-08): shipped EMPLOY share-bar geometry +
        # anti-collision range whisker at y=0.78, label right-anchored at upper cap.
        ax.barh(0, point, height=0.55, color=AU_GREEN, edgecolor=WHITE, linewidth=1.5, zorder=3)
        ax.barh(0, 100 - point, left=point, height=0.55, color=AU_GREY_LIGHT, edgecolor=WHITE,
                linewidth=1.5, zorder=3)
        if point >= 12:
            ax.text(point / 2, 0, f"Livestock  {point:g}%", ha="center", va="center",
                    fontsize=10.5, color=WHITE, fontweight="bold", zorder=5)
        else:
            ax.plot([point, point + 2], [0, 0.42], color=FG_TERTIARY, linewidth=0.8, zorder=4)
            ax.text(point + 2.5, 0.42, f"Livestock  {point:g}%", ha="left", va="bottom",
                    fontsize=9, color=FG_SECONDARY, fontweight="bold", zorder=5)
        if (100 - point) >= 12:
            ax.text(point + (100 - point) / 2, 0, f"Other sources  {100 - point:g}%",
                    ha="center", va="center", fontsize=10.5, color=FG_PRIMARY,
                    fontweight="bold", zorder=5)
        else:
            ax.plot([point, point - 2], [0, -0.42], color=FG_TERTIARY, linewidth=0.8, zorder=4)
            ax.text(point - 2.5, -0.42, f"Other sources  {100 - point:g}%", ha="right", va="top",
                    fontsize=9, color=FG_SECONDARY, zorder=5)
        if hi > lo:
            # Whisker lane y=0.78 (0.23 above bar top 0.55); label right-anchored at
            # the upper endpoint growing leftward — never crosses the in-bar labels.
            ax.plot([lo, hi], [0.78, 0.78], color=AU_MAROON, linewidth=0.9, zorder=6)
            for xv in (lo, hi):
                ax.plot([xv, xv], [0.71, 0.85], color=AU_MAROON, linewidth=0.9, zorder=6)
            ax.text(hi - 0.3, 0.96, f"range {lo:g}–{hi:g}%", ha="right", va="bottom",
                    fontsize=8, color=AU_MAROON, fontstyle="italic", zorder=6)

        ax.set_xlim(0, 100)
        ax.set_ylim(-0.55, 1.05)
        ax.set_yticks([])
        ax.set_xticks([0, 25, 50, 75, 100])
        ax.set_xticklabels(["0%", "25%", "50%", "75%", "100%"], fontsize=8.5, color=FG_SECONDARY)
        ht = chosen["Household_Type"] or "pastoral households"
        # TITLE/SUBTITLE LANES (std1 (c), 2026-09-09): the subtitle was an
        # ax.text at 1.18 beside an axes title with pad=24 — on this 2.6in-tall
        # share-bar canvas the two lanes collide (MLI confirmed). HerdComp
        # pattern: explicit fig-level suptitle lane + figsize-derived wrapped
        # subtitle lane below it; the pipeline's layout top edge is lowered via
        # ctx['title_lane_in'] so the axes can never crowd either lane.
        _cfig = ax.figure  # runtime: the pipeline's Figure (single-axes builder)
        _fig_w_in, _fig_h_in = self.style.resolve_figsize(self.figsize)
        _inc_lines = _wrap_subtitle_lines(
            f"Share of household income from livestock, {ht}.", _fig_w_in, 9.0)
        _inc_sup_h = (15.0 * 1.2) / 72.0 / _fig_h_in    # fig-frac height of suptitle glyphs
        _inc_sub_y = 1.02 - _inc_sup_h - 0.006          # top anchor BELOW suptitle glyphs
        _cfig.suptitle(f"Livestock in {cty}'s Pastoral Household Income", fontsize=15,
                       fontweight="bold", color=AU_GREEN_DARK, x=0.02, ha="left",
                       y=1.02, va="top")
        _cfig.text(0.02, _inc_sub_y, "\n".join(_inc_lines),
                   ha="left", va="top", fontsize=9, color=FG_SECONDARY)
        # Lane stack in inches: suptitle glyphs + wrapped subtitle block + gaps.
        _inc_lane_in = (15.0 * 1.2) / 72.0 + len(_inc_lines) * (9.0 * 1.2) / 72.0 + 0.012
        for s in ("left", "top", "right"):
            ax.spines[s].set_visible(False)
        ax.spines["bottom"].set_color(BORDER_SUBTLE)
        ax.spines["bottom"].set_linewidth(0.8)
        ax.tick_params(left=False, length=3, colors=FG_SECONDARY)

        rows = [dict(ind=r["Indicator_Name"], code=r["Indicator_Code"], val=r["Value"], unit=r["Unit"],
                     yr=r["Year"], src=src_str(r["Source_ID"]), prov=r["Data_Quality"],
                     flag=r["Flag_Reason"]) for _, r in use.iterrows()]
        integrity = (["The complementary 'other sources' segment is 100 − livestock share (an identity, "
                      "not a separately-sourced figure)."])

        return {
            "conflicts_df": share,
            "title_lane_in": _inc_lane_in,
            "source_records": [chosen],
            "year": str(chosen["Year"]) if str(chosen["Year"]).strip() else "",
            "sidecar_rows": rows,
            "source_list": [src_str(chosen["Source_ID"])],
            "design_notes": [
                "Livestock share plotted verbatim; remainder is the arithmetic complement.",
                "Where multiple sources give different shares, the range is shown as a maroon whisker."
            ],
            "integrity": integrity,
            "validation": [f"Livestock share plotted = {point:g}% (verbatim). Range across sources {lo:g}–{hi:g}%."],
        }


# ================================================================== FIG-EMPLOY
def _calculable_share(e):
    """True when a livestock share of employment could be COMPUTED from stated rows:
    a total-employment denominator (persons) beside the direct/indirect counts.
    No such denominator code exists in the datahub today; the helper encodes the
    'or calculable' clause of the owner ruling (2026-09-09)."""
    p = e[(e.Indicator_Code == "EMPLOYMENT_TOTAL") & (e.Unit == "persons")]
    return len(p) > 0


class EmployBuilder(FigureBuilder):
    fig_id = "FIG-EMPLOY"
    title = "Employment in livestock (direct + indirect)"
    chart_type = ("Single horizontal 100%-width bar of the livestock share of employment "
                  "(livestock % vs all other employment), MLI style. Skipped when no share "
                  "of employment is stated or calculable — person counts are never charted.")
    data_file = "DATA_Employment.csv"
    figsize = (6.5, 2.6)  # single share bar (minimax recipe): tight, print-column width

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        e = df[df.Country_Code == iso3]
        if e.empty:
            return "no DATA_Employment rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        e = df[df.Country_Code == iso3]

        def persons(code):
            s = e[(e.Indicator_Code == code) & (e.Unit == "persons")].copy()
            s["val"] = s.Value.apply(fnum)
            s = s[s.val.notna()]
            return s

        direct = persons("EMP_DIRECT")
        indirect = persons("EMP_INDIRECT")
        share = e[(e.Indicator_Code == "EMP_LVST_SHARE") & (e.Unit == "%")].copy()
        share["val"] = share.Value.apply(fnum)
        share = share[share.val.notna()]

        _mf = _load_manifest(self.fig_id, iso3)
        # STEP 2d: plotted share row = the manifest pin (no ranking). The
        # calculable-share fallback below stays dormant (0 EMPLOYMENT_TOTAL rows).
        share_row = _pin_pick(share, _mf, "plotted") if len(share) else None

        # OWNER RULE (2026-09-09, second ruling — supersedes the same-day minimal-bar
        # override): the chart is built ONLY when a livestock share of employment is
        # stated (EMP_LVST_SHARE) or calculable from stated figures (a total-employment
        # denominator row to divide the persons count by). A lone absolute count with
        # no share and no denominator is not plottable here — it belongs in brief text.
        if share_row is None and not _calculable_share(e):
            raise SkipException(
                "no livestock share of employment stated or calculable from stated figures "
                "(owner ruling 2026-09-09: chart only with a share)")

        # OWNER RULE (2026-09-09, third ruling): the figure is ONLY the percentage
        # graph as done for MLI — a single 100%-width share bar. Person counts
        # (EMP_DIRECT / EMP_INDIRECT) are NEVER charted here and the old bold
        # "Livestock = X% of …" context box is gone; counts belong in brief text.
        if share_row is None and _calculable_share(e):
            # Calculable-share fallback (dormant — no EMPLOYMENT_TOTAL denominator
            # rows exist in the datahub today): derive the livestock % from the
            # authoritative persons count ÷ the stated total-employment denominator.
            _tot = e[(e.Indicator_Code == "EMPLOYMENT_TOTAL") & (e.Unit == "persons")].copy()
            _tot["val"] = _tot.Value.apply(fnum)
            _tot = _tot[_tot.val.notna()]
            _cnt = pd.concat([r for r in (direct, indirect) if len(r)], ignore_index=True)
            if len(_tot) and len(_cnt):
                _cr = _clean_rank(_cnt).sort_values(["_fl", "_un", "val"],
                                                    ascending=[True, True, False]).iloc[0]
                _tr = _clean_rank(_tot).sort_values(["_fl", "_un", "val"],
                                                    ascending=[True, True, False]).iloc[0]
                _calc = 100.0 * float(_cr["val"]) / float(_tr["val"])
                share_row = pd.Series({
                    "Indicator_Code": "EMP_LVST_SHARE",
                    "Indicator_Name": "Livestock share of employment (calculated from stated figures)",
                    "Value": _calc, "Unit": "%",
                    "Year": ("" if pd.isna(_cr["Year"]) else _cr["Year"]),
                    "Source_ID": f"{_cr['Source_ID']} / {_tr['Source_ID']}",
                    "Data_Quality": "Calculated", "Flag_Reason": "", "Note": "",
                })
        if share_row is None:
            raise SkipException(
                "no direct/indirect employment counts (persons) and no livestock employment share (%)")

        # minimax-m3 recipe (2026-09-08): tight single-share-bar canvas —
        # the bar is the figure; no dead bands. 6.5in = A4 print column.
        v = fnum(share_row["Value"]) or 0.0
        ax.barh(0, v, height=0.55, color=AU_GREEN, edgecolor=WHITE, linewidth=1.5, zorder=3)
        ax.barh(0, 100 - v, left=v, height=0.55, color=AU_GREY_LIGHT, edgecolor=WHITE,
                linewidth=1.5, zorder=3)
        # BOTH segments labeled, unconditionally (guarantee — never silently drop):
        # inside if the segment is >= 12% of bar width, else outside with leader.
        if v >= 12:
            ax.text(v / 2, 0, f"Livestock  {v:g}%", ha="center", va="center",
                    fontsize=10.5, color=WHITE, fontweight="bold", zorder=5)
        else:
            ax.plot([v, v + 2], [0, 0.42], color=FG_TERTIARY, linewidth=0.8, zorder=4)
            ax.text(v + 2.5, 0.42, f"Livestock  {v:g}%", ha="left", va="bottom",
                    fontsize=9, color=FG_SECONDARY, fontweight="bold", zorder=5)
        if (100 - v) >= 12:
            ax.text(v + (100 - v) / 2, 0, f"Other sources  {100 - v:g}%",
                    ha="center", va="center", fontsize=10.5, color=FG_PRIMARY,
                    fontweight="bold", zorder=5)
        else:
            ax.plot([v, v - 2], [0, -0.42], color=FG_TERTIARY, linewidth=0.8, zorder=4)
            ax.text(v - 2.5, -0.42, f"Other sources  {100 - v:g}%", ha="right", va="top",
                    fontsize=9, color=FG_SECONDARY, zorder=5)
        ax.set_xlim(0, 100)
        ax.set_ylim(-0.55, 0.55)
        ax.set_yticks([])
        ax.set_xticks([0, 25, 50, 75, 100])
        ax.set_xticklabels(["0%", "25%", "50%", "75%", "100%"], fontsize=8.5, color=FG_SECONDARY)
        for s in ("left", "top", "right"):
            ax.spines[s].set_visible(False)
        ax.spines["bottom"].set_color(BORDER_SUBTLE)
        ax.spines["bottom"].set_linewidth(0.8)
        ax.tick_params(left=False, length=3, colors=FG_SECONDARY)
        subtitle = "Livestock employment as a share of national / agricultural employment."

        ax.set_title(f"Employment in {cty}'s Livestock Sector",
                     fontsize=15.5, fontweight="bold", color=AU_GREEN_DARK, pad=30, loc="left")
        ax.text(0, 1.10, subtitle, transform=ax.transAxes, va="bottom", ha="left",
                fontsize=9, color=FG_SECONDARY)

        used_rows = [share_row]
        srcs = "; ".join(sorted({src_str(r["Source_ID"]) for r in used_rows}))

        rows = [dict(ind=r["Indicator_Name"], code=r["Indicator_Code"], val=r["Value"], unit=r["Unit"],
                     yr=r["Year"], src=src_str(r["Source_ID"]), prov=r["Data_Quality"],
                     flag=r["Flag_Reason"]) for r in used_rows]
        integrity = ["Figure charts ONLY the stated/calculable livestock % share of employment "
                     "(owner ruling 2026-09-09); person counts are never plotted on this figure."]

        return {
            "conflicts_df": share,
            "source_records": used_rows,
            "year": "",
            "sidecar_rows": rows,
            "source_list": [srcs],
            "design_notes": [
                "Single 100%-width share bar (MLI style): livestock % vs all other employment.",
                "Chart is built ONLY when a livestock share of employment is stated or "
                "calculable from stated figures (owner ruling 2026-09-09). Person counts "
                "(EMP_DIRECT/EMP_INDIRECT) are never charted on this figure."
            ],
            "integrity": integrity,
            "validation": [f"Plotted: livestock share {v:g}% (verbatim)."],
        }


# ================================================================== FIG-TRADE-ICEBERG
VALUE_UNITS_EXCLUDE = {"head", "tonnes", "kg", "%", "pieces", "litres", ""}


class TradeIcebergBuilder(FigureBuilder):
    fig_id = "FIG-TRADE-ICEBERG"
    title = "Livestock trade: formal vs informal"
    chart_type = ("Vertical 'iceberg' — formal exports stacked by commodity above a waterline; estimated "
                  "informal cross-border exports submerged below (translucent, hatched, range).")
    data_file = "DATA_Trade.csv"
    figsize = (8.6, 7.2)
    keep_left_spine = False
    keep_bottom_spine = False

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        t = df[df.Country_Code == iso3]
        if t.empty:
            return "no DATA_Trade rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        t = df[df.Country_Code == iso3]

        def val(code):
            s = t[t.Indicator_Code == code]
            s = s[~s.Unit.isin(VALUE_UNITS_EXCLUDE)]
            return s

        live  = val("EXPORT_LIVE")
        meat  = val("EXPORT_MEAT")
        hides = val("EXPORT_HIDES")
        infm  = val("EXPORT_INFORMAL")
        if not len(live) and not len(meat) and not len(hides):
            raise SkipException(
                "no money-valued formal export components (live/meat/hides)")

        # OWNER RULE (2026-09-08): the iceberg REQUIRES both sides. Formal-only data
        # cannot draw an iceberg — a partial iceberg implies a measured total.
        # K3 fallback is deliberately NOT implemented: the formal-only bar re-opens the
        # attention-without-support defect. Skip-with-note; the formal figure stays in
        # the brief body text where it belongs.
        if not len(infm):
            raise SkipException(
                "informal-side data absent — an iceberg without the submerged side implies "
                "a completeness the data does not have. Formal-only figures belong in the "
                "brief text (or FIG-EXPORT-TREND), not in an iceberg chart.")

        _uparts = [x for x in (live, meat, hides) if len(x)]
        all_units = pd.concat(_uparts, ignore_index=True) if _uparts else live
        unit = all_units.Unit.mode().iloc[0] if len(all_units) else "USD M"
        is_usd = "usd" in unit.lower() or unit == "USD M"

        _mf = _load_manifest(self.fig_id, iso3)

        def v1(s, lbl):
            # STEP 2d: plotted component row = the manifest pin (no ranking).
            # The pin resolves against the PRE-unit-filter subframe: a pin may
            # legitimately sit on a row captured in a derived unit (SOM-S4-085:
            # 970 unit 'USD M') provided the pin itself declares the
            # conversion — see PIN SCALE RESOLUTION below.
            r = _pin_pick(s, _mf, "component:" + lbl, unit)
            if r is None:
                return 0.0, None
            v = fnum(r["Value"])
            # PIN SCALE RESOLUTION (option 1, task 2026-09-14 SOM): a manifest
            # pin may declare an explicit "scale" for a pinned row captured in
            # a derived unit (SOM-S4-085: 970 unit 'USD M', pin scale 1e6).
            # The builder supports no derived-unit pin natively, so the scale
            # is applied HERE — at the plotted-value resolver, BEFORE frame
            # assembly and label formatting. The master-CSV Value/Unit stay
            # verbatim; the sidecar discloses the plotted value with a
            # conversion note, and the conflict layer keeps matching on the
            # raw row's Value/Unit. Display-axis arithmetic only, mirroring
            # the informal-band _to_unit() normalization below (same defect
            # class). Supersession context: SOM-S1-004 (1.03 bn, CBS AR2024
            # older edition) superseded by SOM-S4-085 (970 USD m, CBS QER
            # 2025Q4 2026) per owner ruling 2026-09-09.
            _pin = next((p for p in _mf.get("pins", [])
                         if p.get("data_point_id") == r.get("Brief_Ref")
                         and str(p.get("slot", "")) == "component:" + lbl), None)
            _sc = fnum(_pin.get("scale")) if _pin else None
            if _sc:
                v = v * _sc
            elif (str(r.get("Unit", "")).strip() or "").lower() != unit.lower():
                # Unresolvable unit mismatch on a pinned row — keep the
                # fail-loud contract (no silent wrong-magnitude plotting).
                raise SystemExit(
                    "FAIL [component pin unit mismatch]: figure %s — pinned row %r "
                    "(slot %s) carries unit %r, figure unit is %r, and the pin "
                    "declares no scale"
                    % (_mf.get("figure", "?"), r.get("Brief_Ref", "?"),
                       "component:" + lbl, str(r.get("Unit", "")), unit))
            return v, r

        comps = []
        for lbl, s, col in [("Meat exports", meat, AU_GREEN),
                            ("Live-animal exports", live, AU_GOLD),
                            ("Hides & skins exports", hides, AU_GREY)]:
            v, r = v1(s, lbl)
            if r is not None and v > 0:
                comps.append((lbl, v, col, r))
        if not comps:
            raise SkipException(
                f"no formal export components share a common money unit ({unit})")
        formal_total = sum(c[1] for c in comps)
        # PIN-SCALE DISCLOSURE (task 2026-09-14 SOM): any component plotted at a
        # pin-declared scale (derived unit, e.g. SOM-S4-085 'USD M' × 1e6) is
        # disclosed in the sidecar so the plotted magnitude is never
        # unexplained. Master Value/Unit stay verbatim.
        _pin_scaled = [(lbl, v, r) for lbl, v, _c, r in comps
                       if (str(r.get("Unit", "")).strip() or "").lower() != unit.lower()]
        _pin_scale_note = ""
        if _pin_scaled:
            _bits = []
            for lbl, v, r in _pin_scaled:
                _sc = fnum(next((p.get("scale") for p in _mf.get("pins", [])
                                 if p.get("data_point_id") == r.get("Brief_Ref")
                                 and str(p.get("slot", "")) == "component:" + lbl), 1))
                # No-sci-notation formatting in provenance text too (owner law).
                _sc_txt = f"{_sc:,.0f}" if float(_sc).is_integer() else f"{_sc:g}"
                _v_txt = f"{v:,.0f}" if float(v).is_integer() else f"{v:g}"
                _bits.append(f"{lbl} {r.get('Brief_Ref','?')} (master row: "
                             f"{r.get('Value','')} {str(r.get('Unit','')).strip()}; "
                             f"plotted ×{_sc_txt} → {_v_txt} {unit})")
            _pin_scale_note = ("pinned row(s) captured in a derived unit; converted to the "
                               "figure unit via the pin's declared scale — "
                               + "; ".join(_bits))

        infm_all = infm
        # STEP 2d: informal submerged rows = the manifest's 'informal' pins.
        infm = _pin_filter(infm_all, _mf, "informal")
        # UNIT NORMALIZATION for the submerged band (defect fix 2026-09-14, UGA):
        # an informal pin captured in RAW dollars under a USD-millions formal
        # stack (or vice versa) must be rescaled to the figure unit or the band
        # silently mis-scales. Value text is untouched — this is display-axis
        # arithmetic on the extracted numeric, disclosed in the sidecar row.
        def _to_unit(v, u):
            if v is None:
                return None
            u = (u or "").strip()
            v = fnum(v)
            if v is None:
                return None
            ul = u.lower()
            vl = unit.lower()
            if ul == vl:
                return v
            if ul == "usd" and vl == "usd m":
                return v / 1e6
            if ul == "usd m" and vl == "usd":
                return v * 1e6
            return None  # incompatible unit — never guess a conversion
        inf_lo = inf_hi = None
        if len(infm):
            _vs = []
            for _, _r in infm.iterrows():
                _v = _to_unit(_r["Value"], _r["Unit"])
                if _v is not None:
                    _vs.append(_v)
            if _vs:
                inf_lo, inf_hi = min(_vs), max(_vs)
        year = comps[0][3]["Year"]

        def money(v):
            return usd(v) if is_usd else money_words(v, unit) or f"{v:g} {unit}"

        xc, w = 0.0, 0.52
        base = 0.0
        for lbl, v, col, _rec in comps:
            ax.bar(xc, v, bottom=base, width=w, color=col, edgecolor=WHITE, linewidth=1.6, zorder=3)
            ax.text(xc, base + v / 2, f"{lbl}\n{money(v)}", ha="center", va="center",
                    fontsize=9.5, color=ink_on(col), fontweight="bold", zorder=4)
            base += v
        ax.text(xc, base + formal_total * 0.045, f"Formal (recorded)\nexports  {money(formal_total)}",
                ha="center", va="bottom", fontsize=10.5, color=AU_GREEN_DARK, fontweight="bold", zorder=4)

        ax.axhline(0, color=AU_TEAL, linewidth=2.2, zorder=5)
        ax.text(-w / 2 - 0.06, 0.0, "waterline", ha="right", va="bottom",
                fontsize=9, color=AU_TEAL, fontstyle="italic", fontweight="bold", zorder=6)
        ax.text(-w / 2 - 0.06, 0.0, "recorded  /  unrecorded", ha="right", va="top",
                fontsize=8, color=FG_TERTIARY, fontstyle="italic", zorder=6)

        if inf_lo is not None:
            ax.bar(xc, -inf_hi, width=w, color=AU_MAROON, alpha=0.30, hatch="\\\\\\",
                   edgecolor=AU_MAROON, linewidth=1.2, zorder=3)
            ax.plot([xc - w / 2, xc + w / 2], [-inf_lo, -inf_lo], color=AU_MAROON,
                    linewidth=1.4, linestyle=":", zorder=4)
            lo_hi = f"{money(inf_lo)}" if inf_lo == inf_hi else \
                (f"{usd(inf_lo)}–{usd(inf_hi)}" if is_usd
                 else (money_words(inf_lo, unit)+"–"+money_words(inf_hi, unit).replace(f" {unit}","")+" "+unit) if money_words(inf_lo,unit)
                 else f"{inf_lo:g}–{inf_hi:g} {unit}")
            ax.text(xc, -inf_hi - formal_total * 0.03,
                    f"Informal cross-border\nexports (estimated)\n{lo_hi}",
                    ha="center", va="top", fontsize=10, color=AU_MAROON, fontweight="bold", zorder=4)

        ax.set_xlim(-0.9, 0.9)
        top = base * 1.20
        bot = -(inf_hi if inf_hi else base) * 1.30
        ax.set_ylim(bot, top)
        ax.set_xticks([]); ax.set_yticks([])
        for sp in ax.spines.values():
            sp.set_visible(False)

        ax.set_title(f"{cty} Livestock Trade: the Iceberg of Informality",
                     fontsize=16, fontweight="bold", color=AU_GREEN_DARK, pad=26, loc="left")
        ax.text(0.0, 1.02,
                f"Formal recorded exports (above) versus estimated informal cross-border trade "
                f"(below), {year}. {unit}.",
                transform=ax.transAxes, va="bottom", ha="left", fontsize=9, color=FG_SECONDARY)

        il = val("IMPORT_LIVE"); im = val("IMPORT_MEAT")
        il = il[il.Unit == unit]; im = im[im.Unit == unit]
        imp_bits = []
        if len(il):
            _ilr = _pin_pick(il, _mf, "import_live")
            imp_bits.append(f"live animals {money(fnum(_ilr['Value']))}")
        if len(im):
            _imr = _pin_pick(im, _mf, "import_meat")
            imp_bits.append(f"meat {money(fnum(_imr['Value']))}")
        if imp_bits:
            ax.text(0.995, -0.02, "Formal imports " + str(year) + ": " + ", ".join(imp_bits),
                    transform=ax.transAxes, va="top", ha="right", fontsize=8.4,
                    color=FG_SECONDARY, fontstyle="italic")

        covered = t[t.Indicator_Code.isin(["EXPORT_LIVE", "EXPORT_MEAT", "EXPORT_HIDES",
                                           "EXPORT_INFORMAL", "IMPORT_LIVE", "IMPORT_MEAT"])]
        inf_note = {infm.iloc[0]["Source_ID"]: "informal"} if len(infm) else None

        rows = []
        for lbl, v, col, rec in comps:
            row_note = ""
            if _pin_scale_note and rec.get("Brief_Ref") in [r2.get("Brief_Ref")
                                                            for _l, _v, r2 in _pin_scaled]:
                row_note = " [plotted at pin-declared scale to figure unit " + unit + "]"
            rows.append(dict(ind=rec["Indicator_Name"], code=rec["Indicator_Code"], val=rec["Value"],
                             unit=rec["Unit"], yr=rec["Year"], src=src_str(rec["Source_ID"]),
                             prov=rec["Data_Quality"] + (f" — {row_note.strip()}" if row_note else ""),
                             flag=rec["Flag_Reason"]))
        for _, r in infm.iterrows():
            rows.append(dict(ind=r["Indicator_Name"], code=r["Indicator_Code"], val=r["Value"],
                             unit=r["Unit"], yr=r["Year"], src=src_str(r["Source_ID"]),
                             prov=r["Data_Quality"] + " (estimated)", flag=r["Flag_Reason"]))
        validation = [
            "Formal export components plotted: " +
            ", ".join(f"{lbl} {money(v)}" for lbl, v, _c, _r in comps) +
            f"  → stacked total {money(formal_total)} (sum verified).",
        ]
        if _pin_scale_note:
            validation.append("Pin-scale conversion: " + _pin_scale_note)
        if inf_lo is not None:
            _inf_units = sorted({str(u).strip() for u in infm.Unit.tolist()})
            _rescale = ""
            if _inf_units and _inf_units != [unit]:
                _rescale = (f" [pinned informal row(s) captured in {'/'.join(_inf_units)}; "
                            f"rescaled to the figure unit {unit} for the shared axis — "
                            "sidecar Value/Unit stay verbatim]")
            validation.append(f"Informal submerged block to {money(inf_hi)} with a low-estimate cap at "
                              f"{money(inf_lo)} (verbatim).{_rescale}")
        integrity = ([
            f"One consistent money unit ({unit}) chosen across components; rows in other units "
            "(head/tonnes/other currencies) are excluded from this figure (no cross-unit mixing).",
            "One idea per figure: the formal-vs-informal EXPORT gap; imports shown as a small annotation.",
        ])

        return {
            "conflicts_df": covered,
            "source_records": [c[3] for c in comps] + [infm, il, im],
            "year": year,
            "sidecar_rows": rows,
            "source_list": sorted({src_str(c[3]["Source_ID"]) for c in comps}) +
                           ([src_str(infm.iloc[0]["Source_ID"]) + " (informal)"] if len(infm) else []),
            "design_notes": [
                "Formal = solid palette segments (recorded); informal = translucent + hatched maroon block "
                "tagged '(estimated)'.",
                "Single value axis, symmetric about the waterline at 0."
            ],
            "integrity": integrity,
            "validation": validation,
            "extra": {"formal_total": formal_total, "informal": (inf_lo, inf_hi), "unit": unit},
        }


# ================================================================== FIG-EXPORT-TREND
EXP_SERIES = [("EXPORT_LIVE", "Live-animal exports", AU_GOLD, "o"),
              ("EXPORT_MEAT", "Meat exports", AU_GREEN, "s"),
              ("EXPORT_HIDES", "Hides & skins exports", AU_GREY, "^")]


class ExportTrendBuilder(FigureBuilder):
    fig_id = "FIG-EXPORT-TREND"
    title = "Livestock export value, over time"
    chart_type = "Multi-series line chart — formal export value by commodity over time, single money unit."
    data_file = "DATA_Trade.csv"
    figsize = (11, 6.0)

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        t = df[df.Country_Code == iso3]
        if t.empty:
            return "no DATA_Trade rows"
        return True

    def build_chart(self, df, iso3, cty, ax):
        t = df[df.Country_Code == iso3]
        codes = [c[0] for c in EXP_SERIES]
        base = t[t.Indicator_Code.isin(codes) & ~t.Unit.isin(VALUE_UNITS_EXCLUDE)].copy()
        base["yr"] = base.Year.apply(fnum)
        base["val"] = base.Value.apply(fnum)
        base = base[base.yr.notna() & base.val.notna()]
        if base.empty:
            raise SkipException( "no money-valued, dated export rows")

        # OWNER/K3 RULE (2026-09-08): suspected_error rows are EXCLUDED from the plot
        # (count disclosed), never drawn with a warning glyph — readers cannot un-read
        # a wrong magnitude. They remain in the dataset with the QC flag.
        _se_mask = base.Flag_Reason.astype(str).str.strip().str.lower() == "suspected_error"
        n_excluded = int(_se_mask.sum())
        base = base[~_se_mask]
        if base.empty:
            raise SkipException(
                f"all export rows carry suspected_error ({n_excluded} excluded) — nothing "
                "publishable until the magnitudes are verified at source")

        priority = {code: len(EXP_SERIES) - i for i, (code, *_r) in enumerate(EXP_SERIES)}

        def _unit_rank(u):
            us = base[base.Unit == u]
            best = max((priority[c] for c in us.Indicator_Code.unique() if c in priority), default=0)
            return (best, int(us.yr.nunique()))

        qualifying = [u for u in base.Unit.unique() if base[base.Unit == u].yr.nunique() >= 2]
        # SINGLE-YEAR FALLBACK (defect fix 2026-09-14, MRT '2024–2024'): when no
        # unit spans >=2 years the old code skipped outright. But the figure can
        # still honour a dated, money-valued single-year level (e.g. MRT hides
        # USD 182,608 in 2024) as a one-point chart — plotted like any sibling
        # point, title '2024' alone (never the degenerate 'Y–Y' pair). Same unit
        # priority ranking; the subtitle names the single year explicitly.
        if not qualifying:
            _sy = [u for u in base.Unit.unique() if len(base[base.Unit == u])]
            if not _sy:
                raise SkipException(
                    "no money-valued, dated export rows on any unit",
                    "Export values exist but none are both money-valued and dated in a "
                    "single consistent unit.")
            unit = max(_sy, key=_unit_rank)
            d = base[base.Unit == unit].copy()
        else:
            unit = max(qualifying, key=_unit_rank)
            d = base[base.Unit == unit].copy()

        # OWNER/K3 SCALE GATE (2026-09-08, from the MLI 0bn defect): values captured
        # under one unit label that span >1e4 in magnitude are unit-mixed (raw USD
        # alongside USD-millions). Plotting them together fabricates near-zero values.
        # Per-series test (same commodity): any mixed series drops ENTIRELY; if every
        # series is mixed, skip the figure — do not silently rescale.
        _mixed = []
        _clean_parts = []
        for code in [c[0] for c in EXP_SERIES]:
            s = d[d.Indicator_Code == code]
            if len(s) and scale_mismatch(s.val.tolist()):
                _mixed.append(code)
            else:
                _clean_parts.append(s)
        if _mixed and not _clean_parts:
            raise SkipException(
                f"unit-mixed magnitudes under '{unit}' in every series "
                f"({', '.join(_mixed)}) — extraction must normalize units before this "
                "trend can be drawn")
        if _mixed:
            d = pd.concat(_clean_parts, ignore_index=False)

        # Cross-commodity gate: everything shares ONE value axis, so the *combined*
        # plotted set must also be magnitude-coherent (MLI: live 2.17e8 next to meat
        # 6.25 and hides 2.38e3 → the small series fabricate "0M" points).
        _all_vals = [v for s in _clean_parts for v in s.val.tolist()]
        if _all_vals and scale_mismatch(_all_vals):
            raise SkipException(
                f"unit-mixed magnitudes across commodities under '{unit}' "
                f"(span > 1e4) — extraction must normalize units before this trend "
                "can be drawn")

        d = dedup_pick(d, ["Indicator_Code", "yr"])
        # STEP 2d: plotted series points = manifest pins (no ranking).
        d = _pin_filter(d, _load_manifest(self.fig_id, iso3), "series")
        is_usd = "usd" in unit.lower() or unit == "USD M"

        _vmax = d.val.max()
        _div, _sfx = ((1e9, "bn") if _vmax >= 1e9 else (1e6, "M") if _vmax >= 1e6 else (1.0, ""))

        def _mfmt(v):
            if not v:
                return "0"
            if _div <= 1:
                return f"{v:g}"
            return f"{v / _div:.1f}".rstrip("0").rstrip(".") + _sfx

        ax.yaxis.grid(True, linestyle="-", color=GRID_COLOR, linewidth=0.6, zorder=0)
        ax.set_axisbelow(True)
        plotted = {}
        all_years = set()
        for code, lbl, col, mk in EXP_SERIES:
            s = d[d.Indicator_Code == code].sort_values("yr")
            if len(s) < 1:
                continue
            xs = [int(y) for y in s.yr.tolist()]
            ys = s.val.tolist()
            all_years.update(xs)
            if len(xs) >= 2:
                ax.plot(xs, ys, "-", color=col, linewidth=2.4, zorder=3, solid_capstyle="round")
            ax.scatter(xs, ys, s=54, marker=mk, facecolor=col, edgecolor=col, linewidth=1.6, zorder=4)
            ax.text(xs[-1] + 0.12, ys[-1], f"{lbl}  {_mfmt(ys[-1])}", va="center", ha="left",
                    fontsize=10, color=col, fontweight="bold", zorder=5)
            plotted[code] = list(zip(xs, [s.Value.tolist()[i] for i in range(len(xs))]))

        if not plotted:
            raise SkipException( "no series to plot after selection")

        yrs = sorted(all_years)
        # SINGLE-YEAR RENDERING (defect fix 2026-09-14, MRT '2024–2024'): when the
        # plotted (pinned) set covers exactly one year there is no trend to draw —
        # render the dated level as its one-point chart: single x tick, title
        # '2024' alone (never the degenerate 'Y–Y' pair), subtitle discloses that
        # only one dated year exists on this unit.
        _single_year = len(yrs) == 1
        if _single_year:
            ax.set_xlim(yrs[0] - 0.9, yrs[0] + 1.9)
            ax.set_xticks(yrs)
            ax.set_xticklabels([str(y) for y in yrs], fontsize=9.5, color=FG_SECONDARY, rotation=0)
            ax.set_ylim(0, None)
            ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: _mfmt(v)))
            ax.set_ylabel(f"Export value ({unit})", fontsize=10, color=FG_SECONDARY)
            ax.set_title(f"{cty} Livestock Export Value, {yrs[0]}",
                         fontsize=16, fontweight="bold", color=AU_GREEN_DARK, pad=28, loc="left")
        else:
            ax.set_xlim(min(yrs) - 0.3, max(yrs) + 1.4)
            ax.set_xticks(yrs)
            ax.set_xticklabels([str(y) for y in yrs], fontsize=9.5, color=FG_SECONDARY, rotation=0)
            ax.set_ylim(0, None)
            ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda v, _: _mfmt(v)))
            ax.set_ylabel(f"Export value ({unit})", fontsize=10, color=FG_SECONDARY)
            ax.set_title(f"{cty} Livestock Export Value, {min(yrs)}–{max(yrs)}",
                         fontsize=16, fontweight="bold", color=AU_GREEN_DARK, pad=28, loc="left")
        if len(plotted) >= 2:
            _sub = f"Formal recorded export value by commodity, in {unit} (verbatim)."
        else:
            _only = next(l for code, l, *_ in EXP_SERIES if code in plotted)
            _sub = f"Formal recorded {_only.lower()} over time, in {unit} (verbatim)."
        if _single_year:
            _sub = (f"Formal recorded export value, {yrs[0]} only (single dated year on this "
                    f"unit), in {unit} (verbatim).")
        # SUBTITLE-WRAP FIX (std1 (d), 2026-09-09): one unwrapped ax.text line —
        # same defect class as GDP/POP-TREND/HERD-COMP subtitles. OWNER RULE:
        # face text wraps to the authored figure width, never widens the image.
        _exp_lines = _wrap_subtitle_lines(
            _sub, self.style.resolve_figsize(self.figsize)[0], 9.0)
        ax.text(0, 1.04, "\n".join(_exp_lines),
                transform=ax.transAxes, va="bottom", ha="left", fontsize=9, color=FG_SECONDARY)

        handles = [Line2D([0], [0], color=c, marker=m, linewidth=2.4, markersize=7,
                          markeredgecolor=c, label=l)
                   for code, l, c, m in EXP_SERIES if code in plotted]
        ax.legend(handles=handles, loc="upper left", frameon=True, facecolor=WHITE,
                  edgecolor=BORDER_DEFAULT, fontsize=9.5, ncol=len(handles))

        covered = t[t.Indicator_Code.isin(codes) & (t.Unit == unit)]

        rows = []
        for code, lbl, _c, _m in EXP_SERIES:
            s = d[d.Indicator_Code == code].sort_values("yr")
            for _, r in s.iterrows():
                rows.append(dict(ind=r["Indicator_Name"], code=r["Indicator_Code"], val=r["Value"],
                                 unit=r["Unit"], yr=r["Year"], src=src_str(r["Source_ID"]),
                                 prov=r["Data_Quality"], flag=r["Flag_Reason"]))
        validation = [f"{lbl}: " + "; ".join(f"{y}:{v}" for y, v in pts) + "  ✓ verbatim."
                      for (code, lbl, _c, _m) in EXP_SERIES for pts in [plotted.get(code)] if pts]
        integrity = ([
            f"Single money unit ({unit}) chosen — the unit that carries the highest-priority commodity "
            "(live animals > meat > hides), then the widest year coverage, so a thin minor-commodity "
            "series never displaces the headline; values plotted verbatim (no currency conversion, no "
            "cross-unit mixing).",
            "One authoritative row per (commodity, year) — conflicting restatements de-duplicated and "
            "disclosed below, not double-plotted.",
        ])

        return {
            "conflicts_df": covered,
            "source_records": [d],
            "year": f"{yrs[0]}" if _single_year else f"{min(yrs)}–{max(yrs)}",
            "source_extra": f"Unit {unit}." + (" Single dated year." if _single_year else ""),
            "sidecar_rows": rows,
            "source_list": sorted({src_str(r["src"]) if False else r["src"] for r in rows}),
            "design_notes": [
                "Commodity colours match FIG-TRADE-ICEBERG (live=gold, meat=green, hides=grey); distinct "
                "markers + direct end labels are the secondary encoding. Single value axis.",
                "The money unit drawn leads with the headline commodity (live animals), then the widest year "
                "coverage — keeping every series on one comparable unit."
            ],
            "integrity": integrity,
            "validation": validation,
            "extra": {"unit": unit, "years": [min(yrs), max(yrs)]},
        }


# ================================================================== FIG-NUTRITION
NUT_TILES = [("CONS_MEAT", "Meat", AU_GREEN, "per capita / yr"),
             ("CONS_MILK", "Milk", AU_GREEN_MID, "per capita / yr"),
             ("CONS_EGG", "Eggs", AU_GOLD, "per capita / yr"),
             ("PROTEIN_SHARE", "Animal-source protein", AU_TEAL, "share of dietary protein"),
             ("STUNTING", "Child stunting", AU_MAROON, "prevalence")]


class NutritionBuilder(FigureBuilder):
    fig_id = "FIG-NUTRITION"
    title = "Animal-source food & nutrition"
    chart_type = ("Stat-tile row — one tile per available animal-source-food / nutrition indicator, each with "
                  "its own value + unit (no shared axis; mixed units).")
    data_file = "DATA_Nutrition.csv"
    figsize = (7.0, 3.4)
    keep_left_spine = False
    keep_bottom_spine = False

    def load_data(self, iso3):
        return _load(self.data_file)

    def gate_check(self, df, iso3, cty):
        n = df[df.Country_Code == iso3]
        if n.empty:
            return "no DATA_Nutrition rows"
        # Pre-compute tiles to determine dynamic figsize
        tiles = self._compute_tiles(n)
        if not tiles:
            return ("no animal-source-food / nutrition indicators (meat/milk/egg consumption, "
                    "protein share, stunting) for this country")
        ncol = len(tiles)
        self.figsize = (max(7.0, 2.15 * ncol + 0.6), 3.4)
        self._tiles = tiles  # cache for build_chart
        return True

    def _compute_tiles(self, n):
        tiles = []
        for code, label, col, cap in NUT_TILES:
            s = n[n.Indicator_Code == code].copy()
            s["val"] = s.Value.apply(fnum)
            s = s[s.val.notna()]
            if not len(s):
                continue
            r = pick(s)
            tiles.append((label, r["Value"], r["Unit"], cap, col, r))
        return tiles

    def build_chart(self, df, iso3, cty, ax):
        n = df[df.Country_Code == iso3]
        tiles = getattr(self, '_tiles', None) or self._compute_tiles(n)
        ncol = len(tiles)
        ax.set_xlim(0, ncol)
        ax.set_ylim(0, 1)
        ax.axis("off")
        for i, (label, value, unit, cap, col, _r) in enumerate(tiles):
            x0 = i + 0.06
            ww = 0.88
            box = FancyBboxPatch((x0, 0.10), ww, 0.74, boxstyle="round,pad=0.02,rounding_size=0.04",
                                 linewidth=0, facecolor=BG_TONAL, zorder=1)
            ax.add_patch(box)
            ax.add_patch(plt.Rectangle((x0, 0.10), 0.05, 0.74, color=col, zorder=2))
            cx = x0 + ww / 2
            ax.text(cx, 0.62, f"{value}", ha="center", va="center", fontsize=21,
                    fontweight="bold", color=col, zorder=3)
            ax.text(cx, 0.47, unit, ha="center", va="center", fontsize=8.5, color=FG_SECONDARY, zorder=3)
            ax.text(cx, 0.30, label, ha="center", va="center", fontsize=10, fontweight="bold",
                    color=FG_PRIMARY, zorder=3)
            ax.text(cx, 0.19, cap, ha="center", va="center", fontsize=7.5, color=FG_TERTIARY,
                    fontstyle="italic", zorder=3)

        ax.text(0.02, 0.955, f"{cty}: Animal-Source Food & Nutrition", ha="left", va="bottom",
                transform=ax.transAxes, fontsize=15.5, fontweight="bold", color=AU_GREEN_DARK)

        used = [tt[5] for tt in tiles]
        srcs = "; ".join(sorted({src_str(r["Source_ID"]) for r in used}))

        rows = [dict(ind=r["Indicator_Name"], code=r["Indicator_Code"], val=r["Value"], unit=r["Unit"],
                     yr=r["Year"], src=src_str(r["Source_ID"]), prov=r["Data_Quality"],
                     flag=r["Flag_Reason"]) for r in used]
        integrity = (["Indicators carry different units (kg, litres, eggs, %) so they are shown as separate "
                      "stat tiles, NOT bars on a shared axis (dataviz: never a single axis across units)."])

        return {
            "conflicts_df": n,
            "source_records": used,
            "year": "",
            "sidecar_rows": rows,
            "source_list": [srcs],
            "design_notes": [
                "Stunting tile uses the maroon alert colour (a deprivation outcome), consumption tiles the "
                "green family, protein-share teal — status/identity, not a rank.",
                "Each value verbatim from the extract; only the authoritative (clean, cited) row per "
                "indicator is shown."
            ],
            "integrity": integrity,
            "validation": [f"{label} = {value} {unit} (verbatim)." for label, value, unit, _c, _col, _r in tiles],
            "extra": {"n_tiles": len(tiles)},
        }


# ================================================================== FIG-GAPS
GAP_CATEGORIES = ["GDP contribution", "Livestock population", "Trade data",
                  "Household surveys", "Market data"]


def categorize_gap(indicator, description):
    s = (indicator + " " + description).lower()
    if any(k in s for k in ["trade", "export", "import", "offtake", "on-the-hoof",
                            "hoof", "cross-border"]):
        return "Trade data"
    if any(k in s for k in ["population", "herd", "census", "tropical livestock", "tlu",
                            "production system", "equine", "cattle", "sheep", "goat",
                            "camel", "poultry", "pig"]):
        return "Livestock population"
    if any(k in s for k in ["gdp", "valuation", "value added", "value-added"]):
        return "GDP contribution"
    if any(k in s for k in ["household", "income", "consumption", "protein", "stunting",
                            "animal-source", "milk", "meat consumption", "egg", "dietary",
                            "subsistence", "food"]):
        return "Household surveys"
    if any(k in s for k in ["employment", "price", "market", "value chain", "workforce",
                            "losses from shocks", "resilience", "wage"]):
        return "Market data"
    return None


class GapsBuilder(FigureBuilder):
    fig_id = "FIG-GAPS"
    title = "Key data gaps by category"
    chart_type = ("Sequential heatmap — Table-3 gap categories (rows) x gap type (columns); cell = count, "
                  "colour intensity = count; row totals annotated.")
    data_file = "DATA_Gaps.csv"
    figsize = (6.5, 3.4)  # minimax recipe: matrix shrinks to content, print-column width
    keep_left_spine = False
    keep_bottom_spine = False
    use_tight_layout = False  # imshow-based heatmap — use subplots_adjust instead

    def load_data(self, iso3):
        """STEP 2e: FIG-GAPS frame aggregated in-engine over the manifest's
        gap_pins Gap_IDs (A2) — the derive_pins.replicate() gap logic re-run
        against the master CSV; feeds build_chart unchanged."""
        mf = _load_manifest(self.fig_id, iso3)
        want = set(mf.get("gap_pins", []))
        rows = [g for g in _gaps_rows() if g["Gap_ID"] in want]
        missing = sorted(want - {g["Gap_ID"] for g in rows})
        if missing:
            raise SystemExit(
                "FAIL [guard ii in-engine]: figure %s/FIG-GAPS — gap_pins %s not "
                "found in the in-engine gap derivation" % (iso3, missing))
        return pd.DataFrame(rows, columns=_SHEET_COLS["DATA_Gaps"])

    def gate_check(self, df, iso3, cty):
        gp = df[df.Country.isin([iso3, cty])]
        if gp.empty:
            return "no DATA_Gaps rows for this country"
        return True

    def build_chart(self, df, iso3, cty, ax):
        gp = df[df.Country.isin([iso3, cty])]
        coltypes = ["Named priority gap", "Missing indicators", "Uncited sources"]
        mat = {c: {ct: 0 for ct in coltypes} for c in GAP_CATEGORIES}
        crosscut = 0
        uncat = []
        for _, r in gp.iterrows():
            gt = r["Gap_Type"]
            if gt.startswith("Named_Gap"):
                cat = categorize_gap(r["Indicator"], r["Description"])
                if cat is None:
                    crosscut += 1
                    continue
                mat[cat]["Named priority gap"] += 1
            elif gt == "Missing_Indicator":
                cat = categorize_gap(r["Indicator"], r["Description"])
                if cat is None:
                    uncat.append(r["Indicator"]); continue
                mat[cat]["Missing indicators"] += 1
            elif gt == "Uncited_Source":
                cat = categorize_gap(r["Indicator"], r["Description"])
                if cat is None:
                    uncat.append(r["Indicator"]); continue
                mat[cat]["Uncited sources"] += 1

        M = np.array([[mat[c][ct] for ct in coltypes] for c in GAP_CATEGORIES], dtype=int)
        row_tot = M.sum(axis=1)
        col_tot = M.sum(axis=0)

        # minimax recipe: greyscale-safe lightness ramp (light→dark = clean→worse);
        # row/col % coverage margins; zero cells show explicit grey dashes.
        cmap = LinearSegmentedColormap.from_list(
            "augrey", ["#F2F2F2", "#D9D9D9", "#737373", "#404040"])
        vmax = max(1, M.max())
        ax.imshow(M, cmap=cmap, vmin=0, vmax=vmax, aspect="auto", zorder=2)

        for i in range(len(GAP_CATEGORIES)):
            for j in range(len(coltypes)):
                v = M[i, j]
                frac = v / vmax
                tcol = WHITE if frac > 0.45 else FG_PRIMARY
                ax.text(j, i, (str(v) if v else "–"), ha="center", va="center", fontsize=9,
                        fontweight="bold", color=(tcol if v else FG_TERTIARY), zorder=4)
            _rpct = int(round(row_tot[i] * 100 / max(1, M.sum())))
            ax.text(len(coltypes) - 0.30, i, f"   {row_tot[i]} ({_rpct}%)", ha="left", va="center",
                    fontsize=8.5, fontweight="bold", color=AU_GREEN_DARK, zorder=4)

        ax.set_xticks(range(len(coltypes)))
        ax.set_xticklabels(["Named\npriority gap", "Missing\nindicators", "Uncited\nsources"],
                           fontsize=8, color=FG_PRIMARY, fontweight="bold")
        ax.set_yticks(range(len(GAP_CATEGORIES)))
        ax.set_yticklabels(GAP_CATEGORIES, fontsize=8.5, color=FG_PRIMARY, fontweight="bold")
        ax.xaxis.tick_top()
        ax.xaxis.set_label_position("top")
        ax.text(len(coltypes) - 0.30, -0.62, "   Total (%)", ha="left", va="center",
                fontsize=8, fontweight="bold", color=AU_GREEN_DARK)

        ax.set_title(f"{cty}: Key Data Gaps by Category", fontsize=16.5, fontweight="bold",
                     color=AU_GREEN_DARK, pad=40, loc="left")
        # DESCRIPTION-LANE FIX (std1 (b), 2026-09-09): the heatmap description was
        # ONE unwrapped 8.6pt ax.text strung BELOW the axes — on wide canvases it
        # ran under the source/footer lanes and collided (MLI confirmed). OWNER
        # RULE: face text wraps to the authored figure width, never widens the
        # image (fig-width-derived budget, 0.55 em/char at 8.6pt — same standard
        # as the HerdComp subtitle). Multi-line va="top" grows DOWN; the lane's
        # inch height depends on the axes height, which depends on rb — so the
        # builder hands the pipeline a closure(fig_h_in, rb) via
        # ctx['footer_extra_in'] and the pipeline iterates rb to fixed point.
        _gaps_desc = ("Count of flagged data gaps, by Table-3 category and gap type. "
                      "Missing indicators & named priority gaps are High severity; "
                      "uncited sources are Medium. Darker = more gaps.")
        _gaps_lines = _wrap_subtitle_lines(
            _gaps_desc, self.style.resolve_figsize(self.figsize)[0], 8.6)
        ax.text(0.0, -0.155, "\n".join(_gaps_lines),
                transform=ax.transAxes, va="top", ha="left", fontsize=8.6, color=FG_SECONDARY)

        def _gaps_footer_in(fig_h_in: float, rb: float) -> float:
            """Inch height of the wrapped description lane below the axes (std1 (b)).

            The axes bottom sits at rb, so the lane's fig-fraction span is
            0.155 (descender offset) + n_lines x line-step + slack; inches =
            span x real fig height. Iterated to fixed point by the pipeline
            (the axes height depends on the very rb this lane feeds)."""
            _line_step = (8.6 * 1.2) / 72.0 / max(fig_h_in, 1e-6)
            _span = 0.155 + len(_gaps_lines) * _line_step + 0.010
            return _span * max(fig_h_in, 1e-6)

        for sp in ax.spines.values():
            sp.set_visible(False)
        ax.tick_params(left=False, top=False)
        ax.set_xticks(np.arange(-.5, len(coltypes), 1), minor=True)
        ax.set_yticks(np.arange(-.5, len(GAP_CATEGORIES), 1), minor=True)
        ax.grid(which="minor", color=BG_CANVAS, linewidth=3)
        ax.tick_params(which="minor", bottom=False, left=False)

        total = int(M.sum()) + crosscut

        rows = []
        for c in GAP_CATEGORIES:
            rows.append(dict(ind=c, code="(Table-3 category)",
                             val=f"priority {mat[c]['Named priority gap']} · missing "
                                 f"{mat[c]['Missing indicators']} · uncited {mat[c]['Uncited sources']}",
                             unit="count", yr="", src="DATA_Gaps (APMD gap assessment)",
                             prov="High (missing/named) · Medium (uncited)", flag=""))
        validation = [
            f"Cell counts sum to {int(M.sum())} categorised + {crosscut} cross-cutting = {total} "
            f"(matches DATA_Gaps rows for {cty}).",
            "Column totals: Named priority {0}, Missing {1}, Uncited {2}.".format(*M.sum(axis=0)),
        ]
        if uncat:
            validation.append("Uncategorised indicators (folded to nearest / none): " + "; ".join(uncat))
        hi_i = int(np.argmax(row_tot)) if row_tot.sum() else 0

        return {
            "conflicts_df": pd.DataFrame(),  # No conflicts for gaps
            "footer_extra_in": _gaps_footer_in,  # std1 (b): desc-lane inches, rb-dependent
            "source_records": [],
            "year": "",
            "source_extra": (f"AU-IBAR APMD gap assessment of the {cty} national livestock brief "
                             f"(DATA_Gaps): {total} flagged gaps ({int(row_tot.sum())} categorised + "
                             f"{crosscut} cross-cutting). AU-IBAR APMD — {cty}."),
            "sidecar_rows": rows,
            "source_list": [f"AU-IBAR APMD gap assessment of the {cty} brief (datahub DATA_Gaps): {total} flagged gaps."],
            "design_notes": [
                "Single-country heatmap (no country axis). Sequential ONE-hue ramp encodes count = magnitude, "
                "per dataviz (never a rainbow).",
                f"Cross-cutting named gaps (general statements / APMD-closed) counted separately: {crosscut}."
            ],
            "integrity": [f"All {total} gaps are High or Medium severity. Highest cluster: "
                          f"'{GAP_CATEGORIES[hi_i]}' ({int(row_tot.max()) if row_tot.sum() else 0} gaps)."],
            "validation": validation,
            "extra": {"total_gaps": total, "matrix": M.tolist()},
        }


# ================================================================== Engine / CLI
BUILDER_CLASSES = [
    GDPBuilder, PopTrendBuilder, HerdCompBuilder, SystemBuilder, IncomeBuilder,
    EmployBuilder, TradeIcebergBuilder, ExportTrendBuilder, GapsBuilder,
]
# FIG-NUTRITION REMOVED from the build roster (owner ruling 2026-09-08, K3-concurred):
# nutrition numbers are incomparable single points and the topic is politically
# sensitive — a chart commands attention without analytical support. The data stays
# in the brief body text; NutritionBuilder is retained below (unused) for reference.


def run_country(iso3, style=None, caption_mgr=None):
    """Run all 10 figure builders for one country (ARCHITECTURE-REDESIGN §4.2 pipeline)."""
    iso3 = iso3.upper()
    cty = country_name(iso3)
    style = style or StyleConfig()
    caption_mgr = caption_mgr or CaptionManager(iso3, cty)

    # STEP 4: pre-build guards — every figure's manifest must exist and satisfy
    # the pin checks BEFORE any figure is drawn (fail loud, nonzero exit).
    for _b in BUILDER_CLASSES:
        _load_manifest(_b.fig_id, iso3)

    print(f"== AU-IBAR chart engine · {cty} ({iso3}) · {TODAY} ==")
    print(f"   Style: {style.target} ({style.dpi} dpi), figsize base: {style.base_width}″")

    results = []
    for BuilderCls in BUILDER_CLASSES:
        builder = BuilderCls(style=style, caption_mgr=caption_mgr)
        try:
            r = builder.build(iso3, cty)
        except Exception as e:
            import traceback
            r = {"fig_id": BuilderCls.fig_id, "status": "error", "reason": repr(e)}
            traceback.print_exc()
        results.append(r)
        tag = r["status"].upper()
        extra = ""
        if r["status"] == "built":
            extra = f"-> {os.path.relpath(r['png'], BRIEFS_DIR)}"
            if r.get("n_conflicts"):
                extra += f"  [{r['n_conflicts']} conflict-flagged]"
        elif r["status"] in ("skipped", "error"):
            extra = f"({r.get('reason','')})"
        print(f"  [{tag:7}] {r['fig_id']:18} {extra}")

    # FIG-NUTRITION DROP SIDECAR (owner ruling 2026-09-08, K3-concurred):
    # the builder is off the roster; every run re-emits the drop record so a
    # stale "built" sidecar can never survive next to a deleted SVG. K3 verdict:
    # drop-as-chart keeps one sourced sentence in the body text; never a chart.
    if "FIG-NUTRITION" not in [b.fig_id for b in BUILDER_CLASSES]:
        SidecarWriter.write_skip(
            iso3, cty, "FIG-NUTRITION", CaptionManager.TITLES["FIG-NUTRITION"],
            "DROPPED AS A CHART (owner ruling 2026-09-08, K3-concurred): animal-source-food / "
            "nutrition numbers are incomparable single points on a politically sensitive topic — "
            "a chart would command attention without analytical support. The figures stay in the "
            "brief BODY TEXT as sourced stat sentences; this figure type is retired from the "
            "visual hierarchy fleet-wide.")

    caption_mgr.save_state()
    built = [r for r in results if r["status"] == "built"]
    print(f"== built {len(built)}/{len(results)} figures for {iso3} ==")
    return results


def main():
    ap = argparse.ArgumentParser(description="AU-IBAR per-country livestock brief chart engine (refactored).")
    ap.add_argument("--iso3", default="KEN", help="ISO3 country code (default KEN)")
    ap.add_argument("--fig", default=None,
                    help="single figure id to (re)build, e.g. FIG-GDP — regenerates only "
                         "that chart's SVG/PNG/sidecar; nothing else is touched")
    ap.add_argument("--all", action="store_true", help="run all ten Member States")
    ap.add_argument("--target", default="print", choices=["screen", "print"],
                    help="output target: screen (200 dpi) or print (300 dpi, 6.5in width)")
    ap.add_argument("--dpi", type=int, default=None, help="override DPI (default: 200 screen, 300 print)")
    args = ap.parse_args()

    style = StyleConfig(target=args.target)
    if args.dpi is not None:
        if args.target == "print":
            style.dpi_print = args.dpi
        else:
            style.dpi_screen = args.dpi

    if args.all:
        allres = {}
        for iso in ALL_ISO3:
            allres[iso] = run_country(iso, style=style)
            print("")
        return allres
    if args.fig:
        # Single-chart regeneration: run ONLY the requested figure type for the
        # requested country. No other chart is touched.
        fig = args.fig.upper()
        if not fig.startswith("FIG-"):
            fig = "FIG-" + fig
        cls = next((c for c in BUILDER_CLASSES if c.fig_id == fig), None)
        if cls is None:
            sys.exit(f"Unknown figure '{fig}'. Available: " +
                     ", ".join(c.fig_id for c in BUILDER_CLASSES))
        cty = country_name(args.iso3.upper())
        builder = cls(style=style, caption_mgr=CaptionManager(args.iso3.upper(), cty))
        r = builder.build(args.iso3.upper(), cty)
        tag = r["status"].upper()
        print(f"  [{tag:7}] {r['fig_id']:18} ({r.get('reason','')})")
        if r["status"] == "built":
            print(f"-> {os.path.relpath(r['png'], BRIEFS_DIR)}")
        return r
    return run_country(args.iso3, style=style)


if __name__ == "__main__":
    main()
