"""Shared base for visible text-mark detectors and localizers.

Each mark supplies a :class:`TextMarkConfig` with its silhouette, expected area,
scale ladder, detector frontend, and calibrated gates. The shared engine locates the
candidate, scores the silhouette with normalized correlation, and builds a removal
footprint for the common fill backend. Individual engines may override detection or
footprint behavior when their measured variant requires it.

The removal path never performs reverse-alpha pixel recovery. Gemini and the Jimeng
pill remain separate engines because their geometry and gating differ from text marks.
"""

# cv2/numpy boundary: third-party libs ship no usable element types; relax the
# unknown-type rules for this file only.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
from __future__ import annotations

import logging
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal

import cv2
import numpy as np

from remove_ai_watermarks import image_io

if TYPE_CHECKING:
    from numpy.typing import NDArray

logger = logging.getLogger(__name__)

# Minimum image short side (px) for text-mark DETECTION. Below this the glyph
# template degrades to the ``min_gw`` floor (~8 px) and TM_CCOEFF_NORMED on a few
# pixels is noise, so an unrelated small geometric shape can spuriously correlate
# with the CJK silhouette (2026-06-26 FP: a 48x48 app icon -- a blue chevron --
# scored Doubao 0.41 / Jimeng 0.47, both above their thresholds). The FP is purely
# a small-size artifact: the same icon upscaled collapses to ~0.06-0.10 NCC at 256
# px and above. A real AI-generation text label is stamped on a full-resolution
# render (the captured samples are 1086-2048 px wide), so 200 px sits far below any
# genuine mark while killing the icon/thumbnail noise band (<=96 px). Detection is
# skipped (verdict stays "unknown", the safe default) rather than risk a false
# positive; removal is gated on detection, so it is suppressed too.
_MIN_DETECT_SHORT_SIDE = 200

# Provenance-confirmed NCC relaxation. When external metadata already confirms the
# vendor (so the mark is present with high prior), a faint or slightly re-rendered
# glyph that scores just below the standard NCC gate is still trusted. The relaxed
# gate is ``detect_ncc_threshold * provenance_ncc_factor``; the coverage gate still
# applies on top.
#
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
# (accepted with provenance, rejected without) over a labelled TC260 evaluation set,
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
#
#   mark     band            precision   95% CI     n
#   doubao   whole arm             76%    61-87%    42
#            [0.280,0.340)         58%    36-77%    19
#            [0.340,0.400)         91%    73-98%    23
#   jimeng   whole arm             17%    10-27%    82
#            [0.315,0.383)         12%     6-22%    68
#            [0.383,0.450)         43%    21-67%    14
#
# Doubao stays at 0.70: both its bands return more true marks than false fills, so
# tightening would cost 11 genuine recoveries to prevent 8 false ones.
#
# Jimeng moves to 0.85. Its relaxed detector does not key on the "★ 即梦AI" wordmark
# any more -- it keys on "some text in the bottom-right corner": of 68 false
# additions, 33 were DOUBAO marks and 17 were other vendors' AI labels (千问, 百度,
# 星绘, 抖音). 45 of those 68 fill a corner nothing else would touch (the other 23
# are harmless -- doubao fires strictly there and fills the same box anyway). At
# 0.85 the [0.315,0.383) band is dropped: 8 genuine recoveries lost, 60 false fills
# prevented (7.5:1). A false fill is the worse error -- it destroys pixels AND makes
# the caller report a removal that did not happen, while a miss leaves the image
# untouched.
#
# NOTE: 0.85 is a patch on a detector problem, not a fix. Jimeng's silhouette is not
# discriminative against Doubao's (same corner, same script, both ByteDance), and no
# threshold repairs that -- it needs a better detection silhouette.
_DEFAULT_PROVENANCE_NCC_FACTOR = 0.7


@dataclass(frozen=True)
class TextMarkConfig:
    """All per-mark tuning for a text-mark detector/localizer."""

    name: str  # short label for log lines (e.g. "Doubao")
    asset_name: str  # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
    corner: Literal[
        "br", "bl", "tl", "bc"
    ]  # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
    margin_floor: int  # min margin in px for locate (4 for br marks, 2 for Samsung)
    # locate geometry (fraction of scale_base -- see scale_base())
    width_frac: float
    height_frac: float
    margin_x_frac: float  # right margin (br) or left margin (bl)
    margin_bottom_frac: float
    # glyph appearance
    max_saturation: float
    logo_min_luma: float
    tophat_delta: float
    morph_open_size: int  # MORPH_OPEN kernel side (5 for br marks, 3 for Samsung)
    # detection
    detect_min_coverage: float
    detect_ncc_threshold: float
    # alpha-map glyph geometry (fraction of scale_base) emitted by
    # scripts/visible_alpha_solve.py, sizing the detection silhouette for
    # template_match_score
    alpha_width_frac: float
    alpha_height_frac: float
    min_gw: int  # minimum glyph width for the template match (8 br, 16 Samsung)
    # Asset names of RIVAL marks that occupy the same corner and can therefore be
    # scored against the same glyph blob. Detection becomes COMPETITIVE: this mark's
    # template must beat every rival's by `rival_margin`. See _rival_margin_ok.
    # Detection front-end. "binary" thresholds the top-hat into a glyph blob and
    # correlates a binary silhouette against it; "tophat" correlates the CONTINUOUS
    # top-hat response against a soft template and never binarizes. See
    # TextMarkEngine.tophat_response for the measurement that motivated the split.
    # "gray" correlates the silhouette against the raw GRAYSCALE of the locate box:
    # for a faint mid-gray mark (RunningHub) the top-hat's background-subtraction and
    # max-normalization suppress the response to clean-arm levels (positives 0.16-0.23
    # vs clean p99 0.31), while raw gray NCC separates (positives 0.38-0.54 vs clean
    # p99 0.264 / max 0.304, measured 2026-07-22). Contrast-DEPENDENT, unlike tophat.
    # "contrast" correlates against the ABSOLUTE local-luma residual. It is for a mark
    # whose renderer switches between light-on-dark and dark-on-light while preserving
    # one silhouette (Tencent Yuanbao); a one-polarity white top-hat misses the latter.
    detect_frontend: Literal["binary", "tophat", "gray", "contrast"] = "binary"
    # Gaussian sigma applied to the template in the "tophat" front-end (0 = none).
    template_blur: float = 0.0
    # Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
    # measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
    scale_basis: Literal["short", "width"] = "width"
    # Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
    # whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
    # (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
    # on 3 rungs covers only ~75% of them). Densifying the SHARED ladder for everyone
    # was measured and rejected (false fire 2.52% -> 3.05%; see docs/verification-plan.md
    # B2), so the default stays the shipped 3 rungs and a deviation must be calibrated
    # per mark on real positives, never ported.
    ladder: tuple[float, ...] = (0.8, 1.0, 1.25)
    rivals: tuple[str, ...] = ()
    rival_margin: float = 0.10
    # Multiplier applied to detect_ncc_threshold when provenance confirms the vendor.
    # Per-mark, NOT shared: see _DEFAULT_PROVENANCE_NCC_FACTOR for the measured
    # precision that forced the split. Last field so it can carry a default.
    provenance_ncc_factor: float = _DEFAULT_PROVENANCE_NCC_FACTOR


@dataclass
class TextMarkLocation:
    """Located watermark box, in absolute pixel coordinates."""

    x: int
    y: int
    w: int
    h: int

    @property
    def bbox(self) -> tuple[int, int, int, int]:
        return self.x, self.y, self.w, self.h


@dataclass
class TextMarkDetection:
    """Result of visible text-mark detection."""

    detected: bool = False
    confidence: float = 0.0
    region: tuple[int, int, int, int] = (0, 0, 0, 0)
    coverage: float = 0.0  # fraction of the box occupied by glyph pixels
    # ROI-local (x0, y0, x1, y1) of the ladder sweep's best match, in the LOCATED BOX's
    # coordinates. None for the ``binary`` front-end (it runs no sweep) and whenever no
    # rung matched. ``footprint_mask`` bounds the fill with it, so carrying it here is
    # what stops the mask path re-running a sweep the detector already ran.
    match_box: tuple[int, int, int, int] | None = None
    # The trust level this detection was taken at, mirroring detect()'s ``provenance``.
    # ``footprint_mask`` reuses a threaded detection only when it matches the STRICT
    # level its own re-detect would have used -- see TextMarkEngine._strict_detection.
    provenance: bool = False


@dataclass(frozen=True)
class TextMarkScan:
    """The trust-level-BLIND half of text-mark detection, reusable across both levels.

    ``loc is None`` means detection stopped before any scan (empty or too-small image).
    ``score is None`` means the binary front-end fell below its coverage gate, which is
    a verdict of "not detected, confidence 0.0" without consulting the rival margin.
    """

    loc: TextMarkLocation | None
    box: NDArray[Any] | None  # box-sized binary glyph mask
    base: int  # scale_base(image)
    frame: tuple[int, int] = (0, 0)  # (h, w) of the scanned image
    coverage: float = 0.0
    score: float | None = None
    match_box: tuple[int, int, int, int] | None = None


# Alpha / silhouette templates, cached per asset name. This shared cache lets every
# text-mark engine reuse the loader without re-reading an asset. Only SUCCESSFUL loads
# are cached, so a missing asset is retried.
_alpha_cache: dict[str, NDArray[Any]] = {}
_silhouette_cache: dict[str, NDArray[Any]] = {}


def load_alpha_template(asset_name: str) -> NDArray[Any] | None:
    """Lazily load the bundled alpha template (float [0,1]) for ``asset_name``, or None."""
    cached = _alpha_cache.get(asset_name)
    if cached is not None:
        return cached
    path = Path(__file__).parent / "assets" / asset_name
    img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
    if img is None:
        return None
    _alpha_cache[asset_name] = img.astype(np.float32) / 255.0
    return _alpha_cache[asset_name]


def glyph_silhouette(asset_name: str) -> NDArray[Any] | None:
    """Binary glyph silhouette (255 = glyph) from the bundled alpha map, or None."""
    cached = _silhouette_cache.get(asset_name)
    if cached is not None:
        return cached
    at = load_alpha_template(asset_name)
    if at is None:
        return None
    _silhouette_cache[asset_name] = (at > 0.15).astype(np.uint8) * 255
    return _silhouette_cache[asset_name]


_RIVAL_MODULES = {
    "doubao_alpha.png": "remove_ai_watermarks.doubao_engine",
    "jimeng_alpha.png": "remove_ai_watermarks.jimeng_engine",
    "samsung_alpha.png": "remove_ai_watermarks.samsung_engine",
}


def _rival_config(asset_name: str, fallback: TextMarkConfig) -> TextMarkConfig:
    """The rival mark's own config, for scoring its template on a shared blob.

    Looked up LAZILY by asset name: a rival's template geometry
    (``alpha_*_frac`` / ``min_gw``) is its own, and scoring it with this mark's
    geometry would compare a correctly-sized template against a mis-sized one and
    hand the margin a free win. Lazy because the engine modules import this one.
    """
    mod_path = _RIVAL_MODULES.get(asset_name)
    if mod_path is None:
        return fallback
    from importlib import import_module

    try:
        return import_module(mod_path)._CONFIG
    except Exception:  # a missing/renamed engine must not break detection
        logger.debug("rival config %s unavailable; skipping its margin check.", asset_name)
        return fallback


def template_match_score(box_mask: NDArray[Any], scale_base: int, config: TextMarkConfig) -> float:
    """Zero-mean normalized correlation of the alpha-template glyph silhouette
    (scaled to the mark's expected size) against the candidate ``box_mask``.

    ``TM_CCOEFF_NORMED`` keys on glyph SHAPE, not coverage, so a dense textured
    corner does not score highly -- only the actual glyph shape does.

    ``scale_base`` is the mark's own scaling dimension (:meth:`TextMarkEngine.scale_base`),
    not always the width: sizing the template on the wrong basis stretches it by the
    aspect ratio on landscape inputs and the correlation collapses.
    """
    sil = glyph_silhouette(config.asset_name)
    if sil is None or box_mask.size == 0:
        return 0.0
    gw = min(box_mask.shape[1] - 1, max(config.min_gw, int(config.alpha_width_frac * scale_base)))
    gh = min(box_mask.shape[0] - 1, max(4, int(config.alpha_height_frac * scale_base)))
    if gw < config.min_gw or gh < 4:
        return 0.0
    template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
    return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())


class TextMarkEngine:
    """Visible text-mark detector/localizer (locate -> mask -> detect; mask feeds the fill)."""

    def __init__(self, config: TextMarkConfig) -> None:
        self.config = config

    # ── Templates (delegate to the asset-keyed module cache) ────────────

    def _alpha_template(self) -> NDArray[Any] | None:
        return load_alpha_template(self.config.asset_name)

    def _glyph_silhouette(self) -> NDArray[Any] | None:
        return glyph_silhouette(self.config.asset_name)

    def _template_match_score(self, box_mask: NDArray[Any], scale_base: int) -> float:
        return template_match_score(box_mask, scale_base, self.config)

    def _rival_margin_ok(self, score: float, box_mask: NDArray[Any], scale_base: int) -> bool:
        """Whether this mark's template beats every same-corner RIVAL's on the SAME blob.

        Detection was purely ABSOLUTE -- each engine scored its own template and
        compared against its own threshold, so nothing ever asked the discriminative
        question "does this blob look more like the neighbour's mark than like mine?".
        Two marks sharing a corner and a script (Doubao "豆包AI生成" and Jimeng
        "★ 即梦AI", both bottom-right, both near-white CJK) survive binarization into
        very similar blobs, so an absolute gate cannot separate them -- and under the
        provenance relaxation it stopped trying because many Jimeng false additions
        were actually Doubao marks.

        Measured separability on hand-labelled examples, scoring BOTH templates
        against the same glyph blob:

            feature                       separability   (0.5 = useless, 1.0 = perfect)
            absolute ncc_jimeng                   0.96
            ncc_jimeng MINUS ncc_doubao           0.99

        A 0.10 margin separated Jimeng wordmarks from the rival and clean examples
        without reducing recall, unlike raising the absolute threshold.

        Marks with no same-corner rival declare `rivals=()` and are unaffected.
        """
        c = self.config
        if not c.rivals:
            return True
        for rival_asset in c.rivals:
            rival = _rival_config(rival_asset, c)
            if score - template_match_score(box_mask, scale_base, rival) < c.rival_margin:
                logger.debug("%s detect: loses the %s rival margin; rejecting.", c.name, rival_asset)
                return False
        return True

    # ── Locate ──────────────────────────────────────────────────────────

    def _roi_fields(
        self, image: NDArray[Any], loc: TextMarkLocation
    ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
        """``(luma, saturation, local_background)`` for the located box, all float32.

        The ROI is normalized to 3-channel BGR first (grayscale / BGRA would break
        ``axis=2``).

        Local background model: a strong Gaussian blur (sigma ~ box height); the white
        top-hat (``luma - local_bg``) lights up bright thin strokes regardless of the
        absolute background level.

        The callers each keep their own ``bh < 16 or bw < 16`` guard: they return three
        different sentinels for a degenerate ROI, so the check cannot move in here.
        """
        x, y, bw, bh = loc.bbox
        roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)
        luma = roi.mean(axis=2)
        sat = roi.max(axis=2) - roi.min(axis=2)
        sigma = max(4.0, bh * 0.4)  # 0.4 factor and 4.0 floor are calibrated; do not retune
        return luma, sat, cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)

    def tophat_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
        """The CONTINUOUS white top-hat in the located box -- the glyph signal, unbinarized.

        :meth:`extract_mask` thresholds this same response into a 0/255 glyph blob. That
        is fine for a mark stamped bold and opaque, and destructive for a faint one: a
        thin translucent overlay shatters into specks under the threshold, and no
        template can match a blob that is not there.

        Calibration on hand-verified examples, scoring each mark with its own template:

            front-end   doubao   clean neg   AUC doubao/neg
            binary       0.723       ~0.12             --
            tophat       0.781        0.122          1.00

        The gates that were hard cuts in the binary path (saturation, absolute luma)
        become WEIGHTS here, so a faint stroke contributes in proportion to its strength
        instead of being dropped at a threshold. The response is max-normalized, which
        makes the score contrast-invariant -- the point of the exercise.

        Kept per-mark (``detect_frontend``) rather than switched globally, because a
        front-end change must be measured per mark before it ships.
        """
        return self._residual_response(image, loc, absolute=False)

    def _residual_response(self, image: NDArray[Any], loc: TextMarkLocation, *, absolute: bool) -> NDArray[Any] | None:
        """Max-normalized uint8 local-luma residual in the located box, saturation-weighted.

        ``absolute=False`` keeps only the POSITIVE side -- the white top-hat, for a mark
        always rendered brighter than its background. ``absolute=True`` takes the
        magnitude, for a renderer that switches between light-on-dark and dark-on-light
        while preserving one silhouette; a one-polarity top-hat misses the latter.
        """
        c = self.config
        _x, _y, bw, bh = loc.bbox
        if bh < 16 or bw < 16:
            return None
        luma, sat, local_bg = self._roi_fields(image, loc)
        residual = luma - local_bg
        resp = (np.abs(residual) if absolute else np.clip(residual, 0, None)) * (sat < c.max_saturation)
        peak = float(resp.max())
        if peak <= 1e-6:
            return None
        return (resp / peak * 255).astype(np.uint8)

    def _detect_response(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any] | None:
        """The uint8 image the ladder sweep correlates the silhouette against, chosen by
        ``TextMarkConfig.detect_frontend``. ``binary`` runs no sweep and never reaches here."""
        frontend = self.config.detect_frontend
        if frontend == "gray":
            x, y, bw, bh = loc.bbox
            if bh < 16 or bw < 16:
                return None
            return cv2.cvtColor(image_io.to_bgr(image[y : y + bh, x : x + bw]), cv2.COLOR_BGR2GRAY)
        if frontend == "contrast":
            return self._residual_response(image, loc, absolute=True)
        if frontend == "tophat":
            return self._residual_response(image, loc, absolute=False)
        raise ValueError(f"{frontend!r} has no ladder response (binary runs no sweep)")

    def _ladder_best(
        self, image: NDArray[Any], loc: TextMarkLocation
    ) -> tuple[float, tuple[int, int, int, int] | None]:
        """Best TM_CCOEFF_NORMED of the mark's silhouette against its front-end response,
        and the ROI-local box (x0, y0, x1, y1) where that best match sits.

        Sweeps the mark's scale ladder: the nominal glyph size is derived from the mark's
        geometry, but a vendor re-rasterization shifts it by a few percent and the
        continuous response is sharp enough that an exact-size template would miss. The
        ladder is per-mark (``TextMarkConfig.ladder``), defaulting to the shipped 3 rungs.

        Detection and the removal mask BOTH read this one method -- the score gates
        detection, the box bounds the fill. Sharing it is deliberate: the standing rule is
        that detection and the mask use the same front-end, and the way that rule was last
        broken was a drift between two separate implementations. One method makes the drift
        impossible instead of merely discouraged -- which is why the three continuous
        front-ends (tophat / contrast / gray) sweep here rather than in a copy each.

        Tie-breaking is load-bearing: the ``0.0`` seed plus the STRICT ``>`` means the
        EARLIEST ladder rung wins a tie, and a sweep whose maximum is not above 0.0
        returns no box at all.
        """
        c = self.config
        resp = self._detect_response(image, loc)
        sil = self._glyph_silhouette()
        if resp is None or sil is None:
            return (0.0, None)
        base = self.scale_base(image)
        best_score = 0.0
        best_box: tuple[int, int, int, int] | None = None
        for scale in c.ladder:
            gw = max(c.min_gw, int(c.alpha_width_frac * base * scale))
            gh = max(4, int(c.alpha_height_frac * base * scale))
            if gw >= resp.shape[1] or gh >= resp.shape[0]:
                continue
            tmpl = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
            if c.detect_frontend == "tophat" and c.template_blur > 0:
                tmpl = cv2.GaussianBlur(
                    tmpl.astype(np.float32), (0, 0), sigmaX=c.template_blur, sigmaY=c.template_blur
                ).astype(np.uint8)
            result = cv2.matchTemplate(resp, tmpl, cv2.TM_CCOEFF_NORMED)
            _, score, _, top_left = cv2.minMaxLoc(result)
            if score > best_score:
                tx, ty = int(top_left[0]), int(top_left[1])
                best_score, best_box = float(score), (tx, ty, tx + gw - 1, ty + gh - 1)
        return (best_score, best_box)

    def scale_base(self, image: NDArray[Any]) -> int:
        """The image dimension this mark's geometry scales with.

        Per-mark, and MEASURED -- a single shared basis is wrong. The tuned fractions
        were all calibrated on PORTRAIT captures, where width and short side coincide,
        so the basis was never exercised until landscape inputs were measured.

        Detector calibration showed that a width-scaled box is inflated by the aspect
        ratio on a wide image and can miss the glyph entirely. A short-side basis
        recovered the affected Doubao landscape cases.

        The same switch broke Jimeng landscape positives because its wordmark tracks
        the WIDTH. Both marks are ByteDance and share a
        corner, and they still scale differently -- so this is a per-mark measurement,
        not a house rule to generalize. Samsung keeps ``width`` because it has not been
        calibrated for a different basis, and an unmeasured change is not an improvement.

        China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the
        shortest side" for CN marks, which is why a short-side basis is the natural
        prior -- but Jimeng's measured behaviour overrides the prior, and measurement
        wins over the standard's wording.
        """
        return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]

    def locate(self, image: NDArray[Any]) -> TextMarkLocation:
        """Anchor the watermark box in the configured corner, scaled by ``scale_basis``.

        Every fraction is taken against ``scale_base(image)`` -- see
        :data:`TextMarkConfig.scale_basis`, which is per-mark because the vendors
        genuinely differ.
        """
        c = self.config
        h, w = image.shape[:2]
        base = self.scale_base(image)
        wm_w = max(40, int(base * c.width_frac))
        wm_h = max(16, int(base * c.height_frac))
        margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
        margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
        if c.corner == "br":
            x = max(0, w - margin_x - wm_w)
        elif c.corner == "bc":  # bottom-center: horizontally centered, margin_x unused
            x = max(0, (w - wm_w) // 2)
        else:
            x = min(margin_x, max(0, w - wm_w))
        # "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
        y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
        wm_w = min(wm_w, w - x)
        wm_h = min(wm_h, h - y)
        return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)

    # ── Mask ────────────────────────────────────────────────────────────

    def extract_mask(self, image: NDArray[Any], loc: TextMarkLocation) -> NDArray[Any]:
        """Build a box-sized uint8 mask (255 = watermark glyph) for ``loc``.

        Returns just the glyph mask of the located box (shape ``(loc.h, loc.w)``),
        not a full-frame array: every caller immediately crops to ``loc.bbox``, so
        allocating a full ``(h, w)`` mask and embedding the box was O(image) work
        and memory for an O(box) result -- a wasted full-frame uint8 allocation on
        each detect (~12 MB on a 12 MP frame, recomputed per text-mark detector on
        the memory-tight identify path). The box mask is byte-identical to the old
        full-frame mask cropped to ``loc.bbox``.

        Polarity-aware: the mark is a light, low-saturation gray rendered brighter
        than the local background (white top-hat), so a white-paper document is left
        untouched (nothing brighter than its surroundings is masked there).
        """
        c = self.config
        x, y, bw, bh = loc.bbox
        # A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
        # the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
        # which can fault native code on some platforms. Skip the cv2 pipeline.
        if bh < 16 or bw < 16:
            return np.zeros((bh, bw), np.uint8)
        # Normalize the ROI to 3-channel BGR (grayscale / BGRA would break axis=2).
        roi = image_io.to_bgr(image[y : y + bh, x : x + bw]).astype(np.float32)

        luma = roi.mean(axis=2)
        sat = roi.max(axis=2) - roi.min(axis=2)
        grayish = sat < c.max_saturation

        # Local background model: a strong Gaussian blur (sigma ~ box height); the
        # white top-hat (luma - local_bg) lights up bright thin strokes regardless
        # of the absolute background level.
        sigma = max(4.0, bh * 0.4)
        local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
        tophat = luma - local_bg

        cand = grayish & (tophat > c.tophat_delta) & (luma > c.logo_min_luma)
        glyph = cand.astype(np.uint8) * 255
        glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
        k = c.morph_open_size
        return cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((k, k), np.uint8))

    # ── Detect ──────────────────────────────────────────────────────────

    def detect(self, image: NDArray[Any], *, provenance: bool = False) -> TextMarkDetection:
        """Detect the mark by matching the alpha-template glyph silhouette against
        the corner candidate (``TM_CCOEFF_NORMED``); keys on glyph SHAPE, not coverage.

        ``provenance`` signals that external metadata already confirms this vendor
        (China-AIGC / byteimg for Doubao/Jimeng, ``samsung_genai`` for Samsung); the
        NCC gate exists to keep a corner texture on an UNRELATED image from matching
        the glyph silhouette, so when provenance confirms the vendor it is relaxed by
        the mark's own ``provenance_ncc_factor`` to recover a faint or slightly
        re-rendered mark (per-mark, not shared -- see _DEFAULT_PROVENANCE_NCC_FACTOR).
        """
        scan = self._scan(image)
        return self._verdict(scan, provenance=provenance)

    def detect_both(self, image: NDArray[Any] | None) -> tuple[TextMarkDetection, TextMarkDetection]:
        """``(strict, relaxed)`` from ONE scan of the image.

        ``provenance`` scales the acceptance THRESHOLD and nothing else -- the locate
        box, the glyph mask, the coverage and the front-end ladder score are computed
        identically at either trust level. Two ``detect`` calls therefore ran the same
        expensive sweep twice to reach two verdicts, which is what the arbiter's
        perception pass did for every mark on every image.

        Returns two DISTINCT objects: subclasses demote a verdict by mutating it.
        """
        scan = self._scan(image)
        return self._verdict(scan, provenance=False), self._verdict(scan, provenance=True)

    def _scan(self, image: NDArray[Any] | None) -> TextMarkScan:
        """Everything in detection that does not depend on the trust level.

        Per-CALL only, never memoized on ``self``: ``remove_auto_marks`` re-invokes each
        engine on a progressively cleaned frame inside one process, so a cached scan
        would answer for the wrong pixels.
        """
        c = self.config
        if image is None or image.size == 0:
            return TextMarkScan(None, None, 0)
        # Guard against the small-image NCC-noise false positive (see
        # _MIN_DETECT_SHORT_SIDE): an icon/thumbnail is too small to carry a real
        # text label, and the degraded few-pixel template spuriously correlates.
        if min(image.shape[:2]) < _MIN_DETECT_SHORT_SIDE:
            logger.debug(
                "%s detect: image short side %d < %d; too small to carry the mark, skipping.",
                c.name,
                min(image.shape[:2]),
                _MIN_DETECT_SHORT_SIDE,
            )
            return TextMarkScan(None, None, 0)
        loc = self.locate(image)
        box = self.extract_mask(image, loc)  # box-sized mask (== old full-frame cropped to bbox)
        _x, _y, bw, bh = loc.bbox
        coverage = float((box > 0).sum()) / float(max(1, bw * bh))
        base = self.scale_base(image)
        match_box: tuple[int, int, int, int] | None = None
        if c.detect_frontend == "binary":
            # The coverage gate is a blob-AREA heuristic, so it applies only to the
            # front-end that binarizes; the continuous ones never build a blob. Below
            # the gate the detection stays at confidence 0.0 and the rival margin is
            # never consulted, so the score stays None here.
            score = self._template_match_score(box, base) if coverage >= c.detect_min_coverage else None
        else:
            score, match_box = self._ladder_best(image, loc)
        return TextMarkScan(loc, box, base, frame=image.shape[:2], coverage=coverage, score=score, match_box=match_box)

    def _verdict(self, scan: TextMarkScan, *, provenance: bool) -> TextMarkDetection:
        """Apply the trust-level-dependent tail to a scan, as a fresh result object."""
        c = self.config
        det = TextMarkDetection(provenance=provenance)
        if scan.loc is None or scan.box is None:
            return det
        det.region = scan.loc.bbox
        det.coverage = scan.coverage
        det.match_box = scan.match_box
        if scan.score is None:  # binary front-end below the coverage gate
            return det
        threshold = c.detect_ncc_threshold * (c.provenance_ncc_factor if provenance else 1.0)
        det.confidence = scan.score
        # Short-circuit is load-bearing: _rival_margin_ok scores every rival template
        # and logs its own rejection line, so it must stay unevaluated below threshold.
        det.detected = scan.score >= threshold and self._rival_margin_ok(scan.score, scan.box, scan.base)
        logger.debug(
            "%s detect (%s): coverage=%.3f ncc=%.2f thr=%.2f detected=%s",
            c.name,
            c.detect_frontend,
            scan.coverage,
            scan.score,
            threshold,
            det.detected,
        )
        return self._post_gate(det, scan)

    def _post_gate(self, det: TextMarkDetection, scan: TextMarkScan) -> TextMarkDetection:
        """Per-mark demotion applied after the shared threshold, for both trust levels.

        OVERRIDABLE. It lives here rather than in a ``detect`` override so a mark's gate
        cannot be silently skipped by the single-pass ``detect_both`` path -- which is
        exactly what happened while the anchor demotions were ``detect`` overrides.
        """
        return det

    # ── Inpaint footprint (for the inpaint-fallback removal path) ────────

    # Minimum glyph pixels for a template-free footprint. Below this the corner has
    # no real wordmark (a few top-hat specks), so without ``force`` there is nothing
    # to mask. A real strip covers hundreds of pixels.
    _MIN_GLYPH_PIXELS = 20

    def _strict_detection(self, image: NDArray[Any], detection: TextMarkDetection | None) -> TextMarkDetection:
        """The STRICT detection the footprint is bounded by.

        A threaded detection is reused only when it was taken at the same strict level
        this method would have used itself. A provenance-RELAXED detection is NOT
        reused: a strict re-detect can demote a mark the relaxed gate accepted, which
        for a continuous front-end means no mask at all. That is a MEASURED difference,
        not a refactor, so the strict semantics stay.

        Reuse is safe against ``remove_auto_marks`` chaining marks on a progressively
        cleaned frame: the registry re-detects on that same cleaned array before
        threading (``KnownMark.localize``), so a threaded detection is never stale.
        """
        if detection is not None and not detection.provenance:
            return detection
        return self.detect(image)  # polymorphic: a subclass gate must still apply

    def _geometry_rect(self, loc: TextMarkLocation, frame: tuple[int, int]) -> tuple[int, int, int, int]:
        """The whole locate box, clamped to the frame -- the ``force`` footprint."""
        bx, by, bw, bh = loc.bbox
        h, w = frame
        return (bx, by, min(w, bx + bw), min(h, by + bh))

    def _extend_match_box(
        self, box: tuple[int, int, int, int], loc: TextMarkLocation, frame: tuple[int, int]
    ) -> tuple[int, int, int, int]:
        """Grow an ROI-local box into the absolute fill rectangle by a symmetric pad.

        OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
        a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
        flat white tag right of the text run, LibLibAI's triangle logo left of the
        wordmark -- supplies its own extension here and inherits the rest of the
        footprint path. The blob-bbox branch never routes through an override.
        """
        gx0, gy0, gx1, gy1 = box
        bx, by, _bw, bh = loc.bbox
        h, w = frame
        pad = max(4, int(0.10 * bh))
        return (
            max(0, bx + gx0 - pad),
            max(0, by + gy0 - pad),
            min(w, bx + gx1 + 1 + pad),
            min(h, by + gy1 + 1 + pad),
        )

    def _footprint_rect(
        self,
        image: NDArray[Any],
        loc: TextMarkLocation,
        *,
        force: bool,
        detection: TextMarkDetection | None,
    ) -> tuple[int, int, int, int] | None:
        """Default footprint policy: the binary glyph blob's bbox, else the detector's
        own match box for the front-ends that under-segment, else the geometry box
        under ``force``.

        The gray front-end exists for marks the top-hat under-segments, so the binary
        blob is NOT authoritative there: trusting it first bounded the fill by a PARTIAL
        blob (the faint head glyphs dropped out) and left the leftmost "Runni" of
        "RunningHub AI生成" unremoved (2026-07-22).

        A dark-on-light Yuanbao mark has no WHITE top-hat blob at all, so the contrast
        front-end is bounded by the polarity-independent detector's match box too.

        A tophat mark found only by the CONTINUOUS front-end has no binary glyph blob to
        bound, so the mask came back empty and removal was a silent no-op while
        ``identify`` still reported the mark. Use the DETECTOR'S OWN best-match box: the
        correlation already located the mark at a position and scale, and thresholding
        the response was a strictly worse proxy for that. An earlier fix thresholded the
        max-normalized uint8 response at 0.5 -- which selects every non-zero pixel, not
        "half the peak" as its comment claimed -- and filled ~120% of the corner box on
        textured frames (measured: whole corner vs 58.7% for the match box, both
        detector-clean). Gated on an actual detection: on a clean corner the box would
        be spurious.
        """
        ys, xs = np.where(self.extract_mask(image, loc) > 0)
        blob = (
            (int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())) if xs.size >= self._MIN_GLYPH_PIXELS else None
        )
        frontend = self.config.detect_frontend
        if frontend in ("gray", "contrast"):
            det = self._strict_detection(image, detection)
            box = det.match_box if det.detected else blob
        elif blob is not None:
            box = blob
        elif frontend == "tophat":
            det = self._strict_detection(image, detection)
            box = det.match_box if det.detected else None
        else:
            box = None
        if box is not None:
            return self._extend_match_box(box, loc, image.shape[:2])
        return self._geometry_rect(loc, image.shape[:2]) if force else None

    def _match_box_rect(
        self,
        image: NDArray[Any],
        loc: TextMarkLocation,
        *,
        force: bool,
        detection: TextMarkDetection | None,
    ) -> tuple[int, int, int, int] | None:
        """Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
        box and never by the binary glyph blob.

        Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
        blob bleeds up into background structure; in both cases the blob bbox is
        measurably wrong and the NCC match box is right. ``force`` takes priority here,
        unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
        geometry box is the honest footprint.
        """
        if force:
            return self._geometry_rect(loc, image.shape[:2])
        det = self._strict_detection(image, detection)
        if not det.detected or det.match_box is None:
            return None
        return self._extend_match_box(det.match_box, loc, image.shape[:2])

    def footprint_mask(
        self,
        image: NDArray[Any] | None,
        *,
        force: bool = False,
        dilate: int | None = None,
        detection: TextMarkDetection | None = None,
    ) -> NDArray[Any] | None:
        """Full-frame uint8 mask (255 = mark) of the mark footprint, for the shared
        fill removal path (cv2 / MI-GAN / LaMa), or None if no glyph is found.

        Template-FREE: localize the glyph blob with the top-hat :meth:`extract_mask`,
        take its bounding box in the corner, and fill that box solid (plus a small
        margin + dilation). Filling the enclosing rectangle -- not the sparse glyph
        strokes -- is what makes it robust: the top-hat under-segments individual
        strokes (which used to leave a "三包"-style residual ghost when the strokes
        themselves were the mask), but the inpaint reconstructs the whole wordmark
        rectangle from its surroundings, so a stroke missed by the top-hat is still
        covered. This drops the fixed alpha-template dependency, so a re-rendered or
        differently-localized mark (e.g. a non-Italian Samsung string) is still masked.

        With ``force`` and no glyph found, falls back to the whole geometry box (the
        ``--no-detect`` path). The caller gates on detection.

        ``detection`` is the caller's already-computed detection, threaded in so the
        footprint does not re-run a sweep the detector already ran. See
        :meth:`_strict_detection` for when it is reused.
        """
        if image is None or image.size == 0:
            return None  # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
        image = image_io.to_bgr(image)
        h, w = image.shape[:2]
        if h < 32 or w < 64:
            return None
        loc = self.locate(image)
        rect = self._footprint_rect(image, loc, force=force, detection=detection)
        if rect is None:
            return None
        rx1, ry1, rx2, ry2 = rect
        if rx1 >= rx2 or ry1 >= ry2:
            return None
        # Rectangular footprint + dilation is exactly region_eraser.boxes_to_mask (the
        # same primitive the shared fill uses); reuse it instead of re-inlining the
        # zeros/fill/MORPH_ELLIPSE-dilate here.
        from remove_ai_watermarks import region_eraser

        d = dilate if dilate is not None else max(3, int(0.02 * loc.w))
        return region_eraser.boxes_to_mask((h, w), [(rx1, ry1, rx2 - rx1, ry2 - ry1)], dilate=d)
