#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Migrate Thomas / Minischoggi product data from Odoo 14 → Odoo 19 (XML-RPC).

Based on `thomas_14_customaddons` product tabs (connector_minischoggi / connector_cakelicious,
product_minischoggi, prestashop_generic_customizations).

Behaviour summary:
  • Copies every **scalar / plain relational field** that exists on BOTH databases with compatible
    types (excluding computed-readonly, one2many, many2many). **Binary image fields** are not copied by
    auto-scalars; the **main template image** and **gallery lines** are copied by dedicated steps (on by default).
  • **Product name** is taken from v14 with ``read`` context ``lang=False`` and written to v19 with the same
    context so the string matches the source default (no UI-language overlay); ``name`` is also set explicitly
    on every ``write`` after ``copy_auto_scalars``.
  • **v14 template id** is stored on v19 ``product.template`` in ``v14_template_id`` (``product_minischoggi``).
    Each run skips v14 products whose id is already present on a v19 template (no duplicate migration).
  • Applies **explicit renames** (Thomas v14 → addonsthomas19 v19), e.g. ``ps_meta_keywords`` →
    ``ps_meta_keyword``, ``cake_*`` shop fields → ``cl_*``.
  • **Minischoggi categories**: ``ps_categ_id`` / ``ps_categ_ids`` plus **connector** Default/M2M categories
    from ``prestashop.product.template`` (backend ``mini`` via ``prestashop.product.category`` bindings and
    ``product.category`` ``prestashop_bind_ids``). Maps to ``ps_category_ids`` and optional tab lines.
  • **Cakelicious categories**: ``cake_ps_categ_*`` plus connector categories from ``prestashop.product.template``
    (backend ``cake``); maps to ``cl_ps_category_ids``.
  • **PrestaShop product IDs**: If ``prestashop_id`` / ``cl_prestashop_id`` on the template are 0, they are
    filled from ``prestashop.product.template`` bindings (``backend_id`` → mini/cake) before scalars and flags.
  • **Features**: v14 may have separate connector rows per backend (mini / cake); v19 keeps one row with
    both ``prestashop_id`` (Minischoggi) and ``cl_prestashop_id`` (Cakelicious). The indexer maps lookup by
    either ID and builds feature–value pairs for both shops.
  • **Nutrition**: PrestaShop ids come from ``prestashop.product.nutrition`` / ``prestashop.product.nutrition.value``
    bindings (``odoo_id`` may be int or ``model,id`` string — both resolved). Masters match v19 by Mini/Cake PS id;
    lines map Minischoggi/Cakelicious ids onto ``prestashop_id`` / ``cl_prestashop_id``.
  • **Features**: mapped from v14 ``product.feature.line`` rows via connector PrestaShop ids (no template M2M read).
  • **Taxes / internal category**: ``taxes_id`` / ``supplier_taxes_id`` mapped by tax **name**, **type_tax_use**
    (sale/purchase), and **tax_scope** when defined on both sides. Writable ``res.currency`` many2ones on the
    template are matched by currency **name** (ISO code). ``categ_id`` mapped via Mini PS binding or name.
  • **Events**: ``product.event`` lines are copied when the model exists on both sides (compatible scalars,
    including **date_start** / **date_end** when both sides use datetime).
  • **Images**: Main ``product.template`` ``image_1920`` (and legacy ``image`` → ``image_1920``) when both DBs
    have the field — default **on** (``--no-migrate-main-image`` to skip). Gallery: ``image_ids`` relation or
    ``common.product.image.ept`` → v19 ``product.template.image``, reading **image_1920**, **image_1024**, or
    **image** per row so binary data is transferred.

Usage:
  python3 minischoggi_migrate_products_v14_to_v19.py --dry-run
  python3 minischoggi_migrate_products_v14_to_v19.py --warnings-file ~/Documents/warnings.log

Environment / CLI override URLs and databases like earlier revisions.
"""

from __future__ import annotations

import argparse
import ast
import json
import logging
import os
import sys
import xmlrpc.client
from typing import Any, Dict, List, Optional, Set, Tuple

# prestashop_api_customizations: skip link_rewrite constraint during RPC migration.
# ``lang: False``: write translatable fields (e.g. ``name``) as unlocalized default so values match source strings.
PRODUCT_TEMPLATE_MIGRATION_CONTEXT = {
    "migration_skip_link_rewrite_constraint": True,
    "lang": False,
}

# v14 ``read``: avoid UI language overlay on translated fields so ``name`` is the exact stored default from v14.
V14_TEMPLATE_READ_CONTEXT = {"lang": False}

# v19 ``product.template`` field from ``product_minischoggi`` — stores source v14 ``product.template`` id (skip if already set).
V14_TEMPLATE_ID_FIELD = "v14_template_id"

# ---------------------------------------------------------------------------
DEFAULT_ODOO14 = {
    "url": os.environ.get("ODOO14_URL", "http://localhost:7079"),
    "db": os.environ.get("ODOO14_DB", "thomas_14_may_09_25"),
    "user": os.environ.get("ODOO_USER", "admin"),
    "password": os.environ.get("ODOO_PASSWORD", "admin"),
}

DEFAULT_ODOO19 = {
    "url": os.environ.get("ODOO19_URL", "http://localhost:8069"),
    "db": os.environ.get("ODOO19_DB", "minischoggi_9_may_25"),
    "user": os.environ.get("ODOO_USER", "admin"),
    "password": os.environ.get("ODOO_PASSWORD", "admin"),
}

LOG = logging.getLogger("minischoggi_product_migration")

ATTR_GET = ["type", "readonly", "compute", "relation", "selection"]

# Thomas v14 connector uses cake_* on template; addonsthomas19 uses cl_* for Cakelicious shop.
CAKE_TO_CL_SCALAR = [
    ("cake_name", "cl_ps_name"),
    ("cake_ps_link_rewrite", "cl_ps_link_rewrite"),
    ("cake_ps_meta_title", "cl_ps_meta_title"),
    ("cake_ps_meta_description", "cl_ps_meta_description"),
    ("cake_ps_meta_keywords", "cl_ps_meta_keyword"),
    ("cake_ps_active", "cl_ps_active"),
    ("cake_ps_active_date", "cl_ps_active_date"),
    ("cake_list_price", "cl_price"),
]

# Odoo 17+ `product.template.valuation` is only `periodic` | `real_time`.
# v14 templates often still carry `manual_periodic`; blind copy breaks write().
VALUATION_LEGACY_TO_V19 = {
    "manual_periodic": "periodic",
    "periodic": "periodic",
    "manual": "periodic",
    "real_time": "real_time",
}

# v19 `product.template.ps_type`: standard | combinations | virtual | pack (screenshot).
# v14 PrestaShop connector often stored `simple` for a normal product → now `standard`.
PS_TYPE_ALLOWED_V19 = frozenset({"standard", "combinations", "virtual", "pack"})
PS_TYPE_LEGACY_TO_V19 = {
    "simple": "standard",
    "standard": "standard",
    "combinations": "combinations",
    "virtual": "virtual",
    "pack": "pack",
}

# v19 `product.template.type`: consu | service | combo (Goods / Service / Combo).
# v14 uses `product` for storable goods → `consu` in Odoo 16+.
PRODUCT_TEMPLATE_TYPE_ALLOWED_V19 = frozenset({"consu", "service", "combo"})
PRODUCT_TEMPLATE_TYPE_LEGACY_TO_V19 = {
    "product": "consu",
    "consu": "consu",
    "service": "service",
    "combo": "combo",
}

SKIP_NAMES = {
    "id",
    "display_name",
    "__last_update",
    "create_uid",
    "create_date",
    "write_uid",
    "write_date",
    "message_follower_ids",
    "message_partner_ids",
    "message_ids",
    "activity_ids",
    "website_message_ids",
}

def _load_state(path: str) -> Dict[str, Any]:
    if not path or not os.path.exists(path):
        return {"last_id": 0, "failed_ids": []}
    with open(path, "r", encoding="utf-8") as fh:
        data = json.load(fh) or {}
    last_id = int(data.get("last_id") or 0)
    failed = data.get("failed_ids") or []
    failed_ids = sorted({int(x) for x in failed if x})
    return {"last_id": last_id, "failed_ids": failed_ids}


def _save_state(path: str, state: Dict[str, Any]) -> None:
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = f"{path}.tmp"
    payload = {
        "last_id": int(state.get("last_id") or 0),
        "failed_ids": sorted({int(x) for x in (state.get("failed_ids") or []) if x}),
    }
    with open(tmp, "w", encoding="utf-8") as fh:
        json.dump(payload, fh, indent=2, sort_keys=True)
        fh.write("\n")
    os.replace(tmp, path)


def connect_odoo(cfg: dict) -> Tuple[int, xmlrpc.client.ServerProxy]:
    common = xmlrpc.client.ServerProxy(f"{cfg['url']}/xmlrpc/2/common")
    uid = common.authenticate(cfg["db"], cfg["user"], cfg["password"], {})
    if not uid:
        raise RuntimeError(
            f"Authentication failed for {cfg['db']} at {cfg['url']} (user={cfg['user']!r})."
        )
    models = xmlrpc.client.ServerProxy(f"{cfg['url']}/xmlrpc/2/object")
    return uid, models


def fields_get(
    models: xmlrpc.client.ServerProxy,
    db: str,
    uid: int,
    password: str,
    model: str,
) -> Dict[str, Any]:
    return models.execute_kw(db, uid, password, model, "fields_get", [[], {"attributes": ATTR_GET}])


def model_exists(
    models: xmlrpc.client.ServerProxy,
    db: str,
    uid: int,
    password: str,
    model: str,
) -> bool:
    n = models.execute_kw(
        db, uid, password, "ir.model", "search_count", [[("model", "=", model)]]
    )
    return bool(n)


def m2o_id(val: Any) -> Optional[int]:
    if not val:
        return None
    if isinstance(val, (list, tuple)):
        return val[0]
    return int(val)


def reference_odoo_id(val: Any) -> Optional[int]:
    """Parse binding ``odoo_id`` from XML-RPC (M2O, Reference ``model,id``, or plain int)."""
    if val is False or val is None:
        return None
    if isinstance(val, int):
        return val
    if isinstance(val, (list, tuple)) and val:
        try:
            return int(val[0])
        except (TypeError, ValueError):
            return None
    if isinstance(val, str):
        s = val.strip()
        if "," in s:
            tail = s.rsplit(",", 1)[-1].strip()
            try:
                return int(tail)
            except ValueError:
                return None
        if s.isdigit():
            return int(s)
    return None


def v14_mini_cake_prestashop_from_bindings(
    binds: List[Dict[str, Any]],
    backend_id_to_code: Dict[int, str],
    mm: MasterMaps,
    label: str,
    context: str,
) -> Tuple[Optional[int], Optional[int]]:
    """
    Connector rows (prestashop.*) carry one prestashop_id per backend.
    Map backend code mini → Minischoggi PS id, cake → Cakelicious PS id; unknown backend → mini fallback.
    """
    ps_mini: Optional[int] = None
    ps_cake: Optional[int] = None
    for b in binds:
        ps_b = b.get("prestashop_id")
        if not ps_b:
            continue
        ps_b = int(ps_b)
        bid = m2o_id(b.get("backend_id"))
        bcode = backend_id_to_code.get(int(bid), "") if bid else ""
        if bcode == "mini":
            ps_mini = ps_b
        elif bcode == "cake":
            ps_cake = ps_b
        else:
            if ps_mini is None:
                ps_mini = ps_b
                mm.warn(
                    f"{label}: {context}: binding backend_id={bid} code={bcode!r} — "
                    f"treating PrestaShop id {ps_b} as Minischoggi (mini)."
                )
    return ps_mini, ps_cake


def v14_bindings_by_odoo_id(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    bind_model: str,
    odoo_ids: List[int],
    bind_fields: Optional[List[str]] = None,
    reference_model: Optional[str] = None,
) -> Dict[int, List[Dict[str, Any]]]:
    """Group prestashop.binding rows by odoo_id (product-side id).

    Some connector bindings use a Reference ``odoo_id`` (``model,id`` string). If integer ``in`` returns
    nothing, retry with ``reference_model`` (e.g. ``product.nutrition.value``).
    """
    out: Dict[int, List[Dict[str, Any]]] = {}
    if not odoo_ids or not model_exists(m14, db14, uid14, pwd, bind_model):
        return out
    fields = bind_fields or ["odoo_id", "prestashop_id", "backend_id"]
    clean_ids = sorted({int(x) for x in odoo_ids if x})
    rows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        bind_model,
        "search_read",
        [[("odoo_id", "in", clean_ids)]],
        {"fields": fields},
    )
    if not rows and reference_model and clean_ids:
        ref_vals = [f"{reference_model},{x}" for x in clean_ids]
        rows = m14.execute_kw(
            db14,
            uid14,
            pwd,
            bind_model,
            "search_read",
            [[("odoo_id", "in", ref_vals)]],
            {"fields": fields},
        )
    for b in rows:
        oid = reference_odoo_id(b.get("odoo_id"))
        if oid:
            out.setdefault(int(oid), []).append(b)
    return out


def v14_backend_id_to_code(
    m14: xmlrpc.client.ServerProxy, db14: str, uid14: int, pwd: str
) -> Dict[int, str]:
    """Map prestashop.backend id → code (e.g. mini, cake)."""
    backs = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.backend",
        "search_read",
        [[]],
        {"fields": ["id", "code"]},
    )
    return {int(b["id"]): (b.get("code") or "").strip() for b in backs}


def enrich_product_template_from_prestashop_bindings(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    tmpl14_id: int,
    blob: Dict[str, Any],
    mm: MasterMaps,
    label: str,
) -> None:
    """
    v14 often leaves product.template `prestashop_id` / `cl_prestashop_id` at 0; the connector stores
    real PS product ids (and default/M2M categories) on `prestashop.product.template` per `backend_id`.
    Mutate `blob` in place: set shop ids, and stash `prestashop.product.category` binding ids for
    merge_categories_* (keys `_ps_pt_cat_bindings_mini` / `_ps_pt_cat_bindings_cake`).
    """
    pt_model = "prestashop.product.template"
    if not model_exists(m14, db14, uid14, pwd, pt_model):
        return
    fg = fields_get(m14, db14, uid14, pwd, pt_model)
    read_f = ["backend_id", "prestashop_id"]
    for opt in (
        "prestashop_default_category_id",
        "prestashop_categ_ids",
        "ps_name",
        "link_rewrite",
    ):
        if opt in fg:
            read_f.append(opt)
    rows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        pt_model,
        "search_read",
        [[("odoo_id", "=", tmpl14_id)]],
        {"fields": read_f},
    )
    if not rows:
        return
    backend_id_to_code = v14_backend_id_to_code(m14, db14, uid14, pwd)
    mini_ps: Optional[int] = None
    cake_ps: Optional[int] = None
    bind_mini_cats: List[int] = []
    bind_cake_cats: List[int] = []

    for r in rows:
        bid = m2o_id(r.get("backend_id"))
        bcode = backend_id_to_code.get(int(bid), "") if bid else ""
        ps = r.get("prestashop_id")
        if ps:
            ps = int(ps)
            if bcode == "mini":
                mini_ps = ps
            elif bcode == "cake":
                cake_ps = ps
            else:
                if mini_ps is None:
                    mini_ps = ps
                    mm.warn(
                        f"{label}: prestashop.product.template backend_id={bid} code={bcode!r} — "
                        f"treating prestashop_id={ps} as Minischoggi."
                    )

        def collect_cats(target: List[int], default_m2o: Any, m2m_val: Any) -> None:
            dc = m2o_id(default_m2o)
            if dc:
                target.append(dc)
            raw_m = m2m_val
            if isinstance(raw_m, list):
                for x in raw_m:
                    if isinstance(x, int) and x:
                        target.append(x)
                    elif isinstance(x, (list, tuple)) and x and isinstance(x[0], int):
                        target.append(int(x[0]))

        if bcode == "mini":
            collect_cats(
                bind_mini_cats,
                r.get("prestashop_default_category_id"),
                r.get("prestashop_categ_ids"),
            )
        elif bcode == "cake":
            collect_cats(
                bind_cake_cats,
                r.get("prestashop_default_category_id"),
                r.get("prestashop_categ_ids"),
            )

    cur_mini = blob.get("prestashop_id")
    if mini_ps and (not cur_mini or int(cur_mini) == 0):
        blob["prestashop_id"] = mini_ps
    cur_cake = blob.get("cl_prestashop_id")
    if cake_ps and (not cur_cake or int(cur_cake) == 0):
        blob["cl_prestashop_id"] = cake_ps

    if bind_mini_cats:
        blob["_ps_pt_cat_bindings_mini"] = list(dict.fromkeys(bind_mini_cats))
    if bind_cake_cats:
        blob["_ps_pt_cat_bindings_cake"] = list(dict.fromkeys(bind_cake_cats))


class MasterMaps:
    def __init__(self) -> None:
        # (prestashop feature id in that shop, prestashop value id in same shop) -> v19 prestashop.feature.value id
        self.feature_value_pair: Dict[Tuple[int, int], int] = {}
        # v19 prestashop.feature.value indexed by shop-specific PrestaShop IDs
        # - Minischoggi: prestashop_id
        # - Cakelicious: cl_prestashop_id
        self.feature_value_by_mini_ps: Dict[int, int] = {}
        self.feature_value_by_cake_ps: Dict[int, int] = {}
        self.feature_by_mini_ps: Dict[int, int] = {}
        self.feature_by_cake_ps: Dict[int, int] = {}
        self.nutrition_by_ps: Dict[int, int] = {}
        self.nutrition_by_cl_ps: Dict[int, int] = {}
        # v19 product.nutrition.portion indexed by shop-specific PrestaShop IDs
        self.portion_by_mini_ps: Dict[int, int] = {}
        self.portion_by_cake_ps: Dict[int, int] = {}
        self.category_by_ps: Dict[int, int] = {}
        self.category_by_cl_ps: Dict[int, int] = {}
        self.warnings: List[str] = []

    def warn(self, msg: str) -> None:
        self.warnings.append(msg)
        LOG.warning(msg)


def _index_put(mm: MasterMaps, index: Dict[int, int], ps_raw: Any, row_id: int, label: str) -> None:
    if ps_raw is None or ps_raw is False:
        return
    ps = int(ps_raw)
    if ps == 0:
        return
    prev = index.get(ps)
    if prev is None:
        index[ps] = row_id
        return
    if prev != row_id:
        mm.warn(f"v19 index clash [{label}] ps_id={ps}: rows {prev} vs {row_id} (keeping first).")


def _feature_pair_put(
    mm: MasterMaps,
    fps: int,
    vps: int,
    fv_row_id: int,
    shop_tag: str,
) -> None:
    key = (fps, vps)
    prev = mm.feature_value_pair.get(key)
    if prev is not None and prev != fv_row_id:
        mm.warn(
            f"v19 duplicate feature/value pair [{shop_tag}] ({fps},{vps}) → rows {prev} vs {fv_row_id} "
            "(first wins)."
        )
    else:
        mm.feature_value_pair.setdefault(key, fv_row_id)


def load_v19_master_maps(
    m19: xmlrpc.client.ServerProxy,
    db: str,
    uid: int,
    password: str,
    mm: MasterMaps,
) -> None:
    def index_ps(model: str, field: str = "prestashop_id") -> Dict[int, int]:
        rows = m19.execute_kw(
            db,
            uid,
            password,
            model,
            "search_read",
            [[(field, "!=", False)]],
            {"fields": ["id", field]},
        )
        out: Dict[int, int] = {}
        for row in rows:
            ps = row.get(field)
            if not ps:
                continue
            ps = int(ps)
            if ps in out and out[ps] != row["id"]:
                mm.warn(f"Duplicate {model}.{field}={ps}: ids {out[ps]} vs {row['id']} (first wins).")
            else:
                out.setdefault(ps, row["id"])
        return out

    mm.category_by_ps = index_ps("product.category", "prestashop_id")
    mm.category_by_cl_ps = index_ps("product.category", "cl_prestashop_id")
    mm.nutrition_by_ps = index_ps("product.nutrition", "prestashop_id")
    fg_nut19 = fields_get(m19, db, uid, password, "product.nutrition")
    if fg_nut19.get("cl_prestashop_id"):
        mm.nutrition_by_cl_ps = index_ps("product.nutrition", "cl_prestashop_id")
    else:
        mm.nutrition_by_cl_ps = {}
    mm.portion_by_mini_ps = index_ps("product.nutrition.portion", "prestashop_id")
    # CL portion IDs exist in v19 (addonsthomas19)
    mm.portion_by_cake_ps = index_ps("product.nutrition.portion", "cl_prestashop_id")
    fg_val = fields_get(m19, db, uid, password, "prestashop.feature.value")
    fg_feat = fields_get(m19, db, uid, password, "prestashop.feature")
    val_fields = {"id", "feature_id"}
    if "prestashop_id" in fg_val:
        val_fields.add("prestashop_id")
    if "cl_prestashop_id" in fg_val:
        val_fields.add("cl_prestashop_id")
    # Pre-index features by shop PS id
    if "prestashop_id" in fg_feat:
        feats_mini = m19.execute_kw(
            db,
            uid,
            password,
            "prestashop.feature",
            "search_read",
            [[("prestashop_id", "!=", False)]],
            {"fields": ["id", "prestashop_id"]},
        )
        for f in feats_mini:
            ps = f.get("prestashop_id")
            if ps:
                _index_put(mm, mm.feature_by_mini_ps, ps, f["id"], "feature_mini")
    if "cl_prestashop_id" in fg_feat:
        feats_cake = m19.execute_kw(
            db,
            uid,
            password,
            "prestashop.feature",
            "search_read",
            [[("cl_prestashop_id", "!=", False)]],
            {"fields": ["id", "cl_prestashop_id"]},
        )
        for f in feats_cake:
            ps = f.get("cl_prestashop_id")
            if ps:
                _index_put(mm, mm.feature_by_cake_ps, ps, f["id"], "feature_cake")

    feat_need = {"prestashop_id"}
    if "cl_prestashop_id" in fg_feat:
        feat_need.add("cl_prestashop_id")

    fval_domain: List[Any] = []
    if "prestashop_id" in fg_val and "cl_prestashop_id" in fg_val:
        fval_domain = ["|", ("prestashop_id", "!=", False), ("cl_prestashop_id", "!=", False)]
    elif "prestashop_id" in fg_val:
        fval_domain = [("prestashop_id", "!=", False)]
    elif "cl_prestashop_id" in fg_val:
        fval_domain = [("cl_prestashop_id", "!=", False)]
    else:
        fval_domain = []

    if fval_domain:
        fvals = m19.execute_kw(
            db,
            uid,
            password,
            "prestashop.feature.value",
            "search_read",
            [fval_domain],
            {"fields": list(val_fields)},
        )
        for fv in fvals:
            fv_id = fv["id"]
            fi = m2o_id(fv.get("feature_id"))
            if not fi:
                continue
            fr_list = m19.execute_kw(
                db,
                uid,
                password,
                "prestashop.feature",
                "read",
                [[fi]],
                {"fields": list(feat_need)},
            )
            if not fr_list:
                continue
            fr = fr_list[0]
            mini_v = fv.get("prestashop_id") if "prestashop_id" in fv else False
            cl_v = fv.get("cl_prestashop_id") if "cl_prestashop_id" in fv else False
            mini_f = fr.get("prestashop_id") if "prestashop_id" in fr else False
            cl_f = fr.get("cl_prestashop_id") if "cl_prestashop_id" in fr else False

            _index_put(mm, mm.feature_value_by_mini_ps, mini_v, fv_id, "mini")
            _index_put(mm, mm.feature_value_by_cake_ps, cl_v, fv_id, "cake")

            if mini_f and mini_v:
                _feature_pair_put(mm, int(mini_f), int(mini_v), fv_id, "Minischoggi")
            if cl_f and cl_v:
                _feature_pair_put(mm, int(cl_f), int(cl_v), fv_id, "Cakelicious")


def normalize_thomas_blob(blob: Dict[str, Any]) -> Dict[str, Any]:
    """Apply cake_* → cl_* and meta keyword rename targets expected on v19."""
    out = dict(blob)
    if out.get("ps_meta_keywords") is not None and "ps_meta_keyword" not in out:
        out["ps_meta_keyword"] = out["ps_meta_keywords"]
    for src, dst in CAKE_TO_CL_SCALAR:
        if src in out and dst not in out:
            out[dst] = out[src]
    return out


def coalesce_template_main_image(blob: Dict[str, Any], fg14: Dict[str, Any]) -> None:
    """Ensure ``image_1920`` is filled from legacy ``image`` when v14 still uses the old field."""
    if blob.get("image_1920"):
        return
    spec = fg14.get("image") or {}
    if spec.get("type") not in ("binary", "image"):
        return
    raw = blob.get("image")
    if raw:
        blob["image_1920"] = raw


def normalize_product_template_valuation(vals: Dict[str, Any], mm: MasterMaps, label: str) -> None:
    """Map/strip legacy valuation keys before write (XML-RPC fields_get may omit choices)."""
    if "valuation" not in vals:
        return
    v = vals["valuation"]
    if v is False or v is None:
        vals.pop("valuation", None)
        return
    allowed = frozenset({"periodic", "real_time"})
    if v in allowed:
        return
    mapped = VALUATION_LEGACY_TO_V19.get(v)
    if mapped in allowed:
        vals["valuation"] = mapped
        mm.warn(f"{label}: mapped legacy valuation {v!r} → {mapped!r} for Odoo 19.")
        return
    mm.warn(
        f"{label}: dropping unsupported valuation {v!r} "
        f"(Odoo 19 allows only periodic, real_time)."
    )
    vals.pop("valuation", None)


def normalize_product_template_ps_type(vals: Dict[str, Any], mm: MasterMaps, label: str) -> None:
    """Map/strip legacy ps_type keys before write (v14 `simple` → v19 `standard`)."""
    if "ps_type" not in vals:
        return
    v = vals["ps_type"]
    if v is False or v is None:
        vals.pop("ps_type", None)
        return
    if v in PS_TYPE_ALLOWED_V19:
        return
    mapped = PS_TYPE_LEGACY_TO_V19.get(v)
    if mapped in PS_TYPE_ALLOWED_V19:
        vals["ps_type"] = mapped
        mm.warn(f"{label}: mapped legacy ps_type {v!r} → {mapped!r} for v19.")
        return
    mm.warn(
        f"{label}: dropping unsupported ps_type {v!r} "
        f"(v19 allows only {sorted(PS_TYPE_ALLOWED_V19)})."
    )
    vals.pop("ps_type", None)


def map_v14_blob_product_template_type(blob: Dict[str, Any]) -> Optional[str]:
    """Map v14 `type` / `detailed_type` to v19 `product.template.type` keys."""
    raw = blob.get("type") or blob.get("detailed_type")
    if raw is False or raw is None:
        return None
    s = raw.strip() if isinstance(raw, str) else str(raw).strip()
    if not s:
        return None
    if s in PRODUCT_TEMPLATE_TYPE_ALLOWED_V19:
        return s
    mapped = PRODUCT_TEMPLATE_TYPE_LEGACY_TO_V19.get(s)
    if mapped in PRODUCT_TEMPLATE_TYPE_ALLOWED_V19:
        return mapped
    return None


def normalize_product_template_type(vals: Dict[str, Any], mm: MasterMaps, label: str) -> None:
    """Map v14 `product` → `consu` before write (copy_auto_scalars may send legacy `type`)."""
    if "type" not in vals:
        return
    v = vals["type"]
    if v is False or v is None:
        return
    if not isinstance(v, str):
        mm.warn(f"{label}: dropping invalid product.template.type {v!r} (expected string).")
        vals.pop("type", None)
        return
    if v in PRODUCT_TEMPLATE_TYPE_ALLOWED_V19:
        return
    mapped = PRODUCT_TEMPLATE_TYPE_LEGACY_TO_V19.get(v)
    if mapped in PRODUCT_TEMPLATE_TYPE_ALLOWED_V19:
        vals["type"] = mapped
        mm.warn(f"{label}: mapped legacy product.template.type {v!r} → {mapped!r} for v19.")
        return
    mm.warn(
        f"{label}: dropping unsupported product.template.type {v!r} "
        f"(v19 allows only {sorted(PRODUCT_TEMPLATE_TYPE_ALLOWED_V19)})."
    )
    vals.pop("type", None)


def should_skip_auto_field(fname: str, spec: Dict[str, Any]) -> bool:
    if fname in SKIP_NAMES or fname.startswith("message_"):
        return True
    ftype = spec.get("type")
    if ftype in ("one2many", "many2many", "many2one"):
        return True
    if spec.get("compute") and spec.get("readonly"):
        return True
    if ftype in ("binary", "image"):
        return True
    return False


def copy_auto_scalars(
    blob: Dict[str, Any],
    fg14: Dict[str, Any],
    fg19: Dict[str, Any],
) -> Dict[str, Any]:
    """Copy primitive fields only (never Many2one IDs — they differ between databases)."""
    vals: Dict[str, Any] = {}
    for fname, spec14 in fg14.items():
        if fname not in fg19:
            continue
        spec19 = fg19[fname]
        if should_skip_auto_field(fname, spec14) or should_skip_auto_field(fname, spec19):
            continue
        if spec14.get("type") != spec19.get("type"):
            continue
        if fname not in blob:
            continue
        vals[fname] = blob[fname]
    return vals


def map_many2one_by_category_ps(
    mm: MasterMaps,
    cat_row_v14: Dict[str, Any],
    label: str,
    use_cl: bool,
) -> Optional[int]:
    field = "cl_prestashop_id" if use_cl else "prestashop_id"
    index = mm.category_by_cl_ps if use_cl else mm.category_by_ps
    ps = cat_row_v14.get(field)
    if not ps:
        mm.warn(f"{label}: v14 category id={cat_row_v14.get('id')} missing {field}.")
        return None
    tgt = index.get(int(ps))
    if not tgt:
        mm.warn(f"{label}: v19 product.category {field}={ps} not found.")
    return tgt


def merge_categories_mini(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    tmpl14: Dict[str, Any],
    mm: MasterMaps,
    label: str,
) -> List[int]:
    ids_v14: List[int] = []
    for key in ("ps_categ_id", "ps_categ_ids"):
        raw = tmpl14.get(key)
        if not raw:
            continue
        if isinstance(raw, list):
            ids_v14.extend([x for x in raw if isinstance(x, int)])
        else:
            cid = m2o_id(raw)
            if cid:
                ids_v14.append(cid)
    # Connector `prestashop.product.template` → default / extra PS categories (prestashop.product.category ids)
    for ps_pc_id in tmpl14.get("_ps_pt_cat_bindings_mini") or []:
        if not ps_pc_id:
            continue
        try:
            pid = int(ps_pc_id)
        except (TypeError, ValueError):
            continue
        pc_rows = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "prestashop.product.category",
            "read",
            [[pid]],
            {"fields": ["odoo_id"]},
        )
        if not pc_rows:
            continue
        oc = m2o_id(pc_rows[0].get("odoo_id"))
        if oc:
            ids_v14.append(int(oc))
    ids_v14 = list(dict.fromkeys(ids_v14))
    if not ids_v14:
        return []
    # v14 has no product.category.prestashop_id; use prestashop bindings
    crows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.category",
        "read",
        [ids_v14],
        {"fields": ["id", "name"]},
    )
    out: List[int] = []
    for c in crows:
        # Find binding for backend mini and take prestashop_id from binding
        binds = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "prestashop.product.category",
            "search_read",
            [[("odoo_id", "=", c["id"])]],
            {"fields": ["backend_id", "prestashop_id"]},
        )
        ps_id = None
        for b in binds:
            bid = m2o_id(b.get("backend_id"))
            if not bid:
                continue
            back = m14.execute_kw(
                db14, uid14, pwd, "prestashop.backend", "read", [[bid]], {"fields": ["code"]}
            )
            code = back and back[0].get("code")
            if code == "mini" and b.get("prestashop_id"):
                ps_id = int(b["prestashop_id"])
                break
        if not ps_id:
            mm.warn(f"{label}: v14 category {c.get('name')!r} has no prestashop binding for mini.")
            continue
        mid = mm.category_by_ps.get(ps_id)
        if not mid:
            mm.warn(f"{label}: v19 product.category prestashop_id={ps_id} missing (mini).")
            continue
        out.append(mid)
    return out


def merge_categories_cake(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    tmpl14: Dict[str, Any],
    mm: MasterMaps,
    label: str,
) -> List[int]:
    ids_v14: List[int] = []
    for key in ("cake_ps_categ_id", "cake_ps_categ_ids"):
        raw = tmpl14.get(key)
        if not raw:
            continue
        if isinstance(raw, list):
            ids_v14.extend([x for x in raw if isinstance(x, int)])
        else:
            cid = m2o_id(raw)
            if cid:
                ids_v14.append(cid)
    for ps_pc_id in tmpl14.get("_ps_pt_cat_bindings_cake") or []:
        if not ps_pc_id:
            continue
        try:
            pid = int(ps_pc_id)
        except (TypeError, ValueError):
            continue
        pc_rows = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "prestashop.product.category",
            "read",
            [[pid]],
            {"fields": ["odoo_id"]},
        )
        if not pc_rows:
            continue
        oc = m2o_id(pc_rows[0].get("odoo_id"))
        if oc:
            ids_v14.append(int(oc))
    ids_v14 = list(dict.fromkeys(ids_v14))
    if not ids_v14:
        return []
    crows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.category",
        "read",
        [ids_v14],
        {"fields": ["id", "name"]},
    )
    out: List[int] = []
    for c in crows:
        binds = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "prestashop.product.category",
            "search_read",
            [[("odoo_id", "=", c["id"])]],
            {"fields": ["backend_id", "prestashop_id"]},
        )
        ps_id = None
        for b in binds:
            bid = m2o_id(b.get("backend_id"))
            if not bid:
                continue
            back = m14.execute_kw(
                db14, uid14, pwd, "prestashop.backend", "read", [[bid]], {"fields": ["code"]}
            )
            code = back and back[0].get("code")
            if code == "cake" and b.get("prestashop_id"):
                ps_id = int(b["prestashop_id"])
                break
        if not ps_id:
            mm.warn(f"{label}: v14 category {c.get('name')!r} has no prestashop binding for cake.")
            continue
        mid = mm.category_by_cl_ps.get(ps_id)
        if not mid:
            mm.warn(f"{label}: v19 product.category cl_prestashop_id={ps_id} missing (cake).")
            continue
        out.append(mid)
    return out


def sync_tab_lines(
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    pwd: str,
    _mm: MasterMaps,
    model_line: str,
    tmpl19_id: int,
    category_ids19: List[int],
    dry_run: bool,
    replace: bool,
    label: str,
) -> None:
    if not category_ids19 or not model_exists(m19, db19, uid19, pwd, model_line):
        return
    if replace and not dry_run:
        existing = m19.execute_kw(
            db19,
            uid19,
            pwd,
            model_line,
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if existing:
            m19.execute_kw(db19, uid19, pwd, model_line, "unlink", [existing])
    for cid in category_ids19:
        if dry_run:
            continue
        m19.execute_kw(
            db19,
            uid19,
            pwd,
            model_line,
            "create",
            [{"product_tmpl_id": tmpl19_id, "category_id": cid}],
        )


def map_manufacturer_partner(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    mm: MasterMaps,
    manufacturer_m2o: Any,
    label: str,
) -> Optional[int]:
    """
    v14 uses `product.template.manufacturer` (res.partner).
    v19 expects `product.template.manufacturer_id` (related to product.product.manufacturer_id).

    We map by exact partner name (company) to avoid copying raw IDs across DBs.
    """
    pid14 = m2o_id(manufacturer_m2o)
    if not pid14:
        return None
    prow = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "res.partner",
        "read",
        [[pid14]],
        {"fields": ["name", "is_company"]},
    )
    if not prow:
        return None
    name = (prow[0].get("name") or "").strip()
    if not name:
        mm.warn(f"{label}: v14 manufacturer partner id={pid14} has empty name.")
        return None

    ids19 = m19.execute_kw(
        db19,
        uid19,
        pwd,
        "res.partner",
        "search",
        [[("name", "=", name), ("is_company", "=", True)]],
        {"limit": 2},
    )
    if len(ids19) == 1:
        return ids19[0]
    if len(ids19) == 0:
        mm.warn(f"{label}: manufacturer partner {name!r} not found in v19.")
        return None
    mm.warn(f"{label}: manufacturer partner {name!r} not unique in v19 (found {len(ids19)}).")
    return None


def map_account_taxes_m2m(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tax_m2o_val: Any,
    mm: MasterMaps,
    label: str,
    field_name: str,
) -> List[int]:
    """Map v14 ``account.tax`` → v19 by **name**, **type_tax_use** (sale/purchase/none), and **tax_scope** when present."""
    if not tax_m2o_val:
        return []
    ids14: List[int] = []
    if isinstance(tax_m2o_val, list):
        for x in tax_m2o_val:
            if isinstance(x, int):
                ids14.append(x)
            elif isinstance(x, (list, tuple)) and x and isinstance(x[0], int):
                ids14.append(int(x[0]))
    if not ids14 or not model_exists(m14, db14, uid14, pwd, "account.tax"):
        return []
    if not model_exists(m19, db19, uid19, pwd, "account.tax"):
        return []
    fg14t = fields_get(m14, db14, uid14, pwd, "account.tax")
    fg19t = fields_get(m19, db19, uid19, pwd, "account.tax")
    read_fields = ["name"]
    if fg14t.get("type_tax_use") and fg19t.get("type_tax_use"):
        read_fields.append("type_tax_use")
    if fg14t.get("tax_scope") and fg19t.get("tax_scope"):
        read_fields.append("tax_scope")
    rows = m14.execute_kw(db14, uid14, pwd, "account.tax", "read", [ids14], {"fields": read_fields})
    out: List[int] = []
    for t in rows or []:
        name = (t.get("name") or "").strip()
        if not name:
            continue
        domain: List[Any] = [("name", "=", name)]
        ttu = t.get("type_tax_use")
        if "type_tax_use" in read_fields and ttu not in (False, None):
            domain.append(("type_tax_use", "=", ttu))
        ts = t.get("tax_scope")
        if "tax_scope" in read_fields and ts not in (False, None):
            domain.append(("tax_scope", "=", ts))
        found = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "account.tax",
            "search",
            [domain],
            {"limit": 2},
        )
        if len(found) == 1:
            out.append(int(found[0]))
        elif len(found) == 0:
            mm.warn(
                f"{label}: {field_name}: no v19 account.tax for name={name!r} "
                f"type_tax_use={ttu!r} tax_scope={ts!r}."
            )
        else:
            mm.warn(
                f"{label}: {field_name}: multiple v19 taxes for name={name!r} "
                f"type_tax_use={ttu!r} tax_scope={ts!r}."
            )
    return out


def map_res_currency_m2o_by_name(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    currency_m2o_val: Any,
    mm: MasterMaps,
    label: str,
    field_name: str,
) -> Optional[int]:
    """Map ``res.currency`` many2one by ISO code in ``name`` (e.g. CHF, EUR)."""
    cid14 = m2o_id(currency_m2o_val)
    if not cid14:
        return None
    if not model_exists(m14, db14, uid14, pwd, "res.currency"):
        return None
    if not model_exists(m19, db19, uid19, pwd, "res.currency"):
        return None
    crow = m14.execute_kw(
        db14, uid14, pwd, "res.currency", "read", [[cid14]], {"fields": ["name", "currency_unit_label"]}
    )
    if not crow:
        return None
    code = (crow[0].get("name") or "").strip()
    if not code:
        return None
    found = m19.execute_kw(
        db19,
        uid19,
        pwd,
        "res.currency",
        "search",
        [[("name", "=", code)]],
        {"limit": 2},
    )
    if len(found) == 1:
        return int(found[0])
    if len(found) == 0:
        mm.warn(f"{label}: {field_name}: v19 has no res.currency with name={code!r}.")
        return None
    mm.warn(f"{label}: {field_name}: res.currency {code!r} not unique in v19.")
    return None


def apply_res_currency_many2one_fields(
    blob: Dict[str, Any],
    fg14_all: Dict[str, Any],
    fg19_all: Dict[str, Any],
    vals: Dict[str, Any],
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    mm: MasterMaps,
    label: str,
) -> None:
    """Fill writable ``res.currency`` many2ones on ``product.template`` by matching currency **name** (code)."""
    for fname, spec19 in fg19_all.items():
        if spec19.get("type") != "many2one" or spec19.get("relation") != "res.currency":
            continue
        if spec19.get("readonly"):
            continue
        if spec19.get("compute") and spec19.get("readonly"):
            continue
        spec14 = fg14_all.get(fname)
        if not spec14 or spec14.get("type") != "many2one" or spec14.get("relation") != "res.currency":
            continue
        if fname not in blob or not blob.get(fname):
            continue
        mapped = map_res_currency_m2o_by_name(
            m14,
            db14,
            uid14,
            pwd,
            m19,
            db19,
            uid19,
            blob.get(fname),
            mm,
            label,
            fname,
        )
        if mapped:
            vals[fname] = mapped


def map_internal_categ_id(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    categ_m2o: Any,
    mm: MasterMaps,
    label: str,
) -> Optional[int]:
    """
    Map v14 internal ``product.template.categ_id`` → v19 ``product.category`` id.
    Prefer PrestaShop id via ``prestashop.product.category`` binding (backend mini), else name/complete_name.
    """
    cid14 = m2o_id(categ_m2o)
    if not cid14:
        return None
    crows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.category",
        "read",
        [[cid14]],
        {"fields": ["id", "name", "complete_name"]},
    )
    if not crows:
        return None
    c0 = crows[0]
    binds = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.product.category",
        "search_read",
        [[("odoo_id", "=", cid14)]],
        {"fields": ["backend_id", "prestashop_id"]},
    )
    ps_id: Optional[int] = None
    for b in binds or []:
        bid = m2o_id(b.get("backend_id"))
        if not bid:
            continue
        back = m14.execute_kw(
            db14, uid14, pwd, "prestashop.backend", "read", [[bid]], {"fields": ["code"]}
        )
        code = back and back[0].get("code")
        if code == "mini" and b.get("prestashop_id"):
            ps_id = int(b["prestashop_id"])
            break
    if ps_id:
        tgt = mm.category_by_ps.get(ps_id)
        if tgt:
            return int(tgt)
        mm.warn(f"{label}: internal category v14 id={cid14}: v19 missing prestashop_id={ps_id}.")
    cname = (c0.get("complete_name") or c0.get("name") or "").strip()
    if cname:
        found = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.category",
            "search",
            [[("complete_name", "=", cname)]],
            {"limit": 2},
        )
        if len(found) == 1:
            return int(found[0])
    short = (c0.get("name") or "").strip()
    if short:
        found = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.category",
            "search",
            [[("name", "=", short)]],
            {"limit": 2},
        )
        if len(found) == 1:
            return int(found[0])
        if len(found) > 1:
            mm.warn(f"{label}: internal category name {short!r} ambiguous in v19.")
    return None


def map_feature_value_from_connector_line(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    mm: MasterMaps,
    fv_odoo_id: int,
    backend_codes: List[str],
    label: str,
) -> Optional[int]:
    """Resolve connector bindings → v19 row (single row holds Mini `prestashop_id` and CL `cl_prestashop_id`)."""
    backs = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.backend",
        "search_read",
        [[("code", "in", backend_codes)]],
        {"fields": ["id", "code"]},
    )
    backend_ids = [b["id"] for b in backs]
    backend_id_to_code = {int(b["id"]): (b.get("code") or "") for b in backs}
    if not backend_ids:
        mm.warn(f"{label}: no prestashop.backend in {backend_codes} on v14.")
        return None

    binds = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.product.feature.values",
        "search_read",
        [[("odoo_id", "=", fv_odoo_id), ("backend_id", "in", backend_ids)]],
        {"fields": ["prestashop_id", "backend_id"]},
    )
    if not binds:
        mm.warn(f"{label}: no prestashop binding for product.feature.values id={fv_odoo_id}.")
        return None

    feat_rows = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.feature.values",
        "read",
        [[fv_odoo_id]],
        {"fields": ["feature_id"]},
    )
    feat_odoo = feat_rows and m2o_id(feat_rows[0].get("feature_id"))
    fbinds: List[Dict[str, Any]] = []
    if feat_odoo:
        fbinds = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "prestashop.product.features",
            "search_read",
            [[("odoo_id", "=", feat_odoo), ("backend_id", "in", backend_ids)]],
            {"fields": ["prestashop_id", "backend_id"]},
        )

    for vb in binds:
        ps_v = vb.get("prestashop_id")
        if not ps_v:
            continue
        ps_v = int(ps_v)
        bid = m2o_id(vb.get("backend_id"))
        bcode = backend_id_to_code.get(int(bid)) if bid else ""
        ps_f = None
        if feat_odoo and bid:
            for fb in fbinds:
                if m2o_id(fb.get("backend_id")) == bid and fb.get("prestashop_id"):
                    ps_f = int(fb["prestashop_id"])
                    break
        if ps_f:
            pair = mm.feature_value_pair.get((ps_f, ps_v))
            if pair:
                return pair
        # Backend-specific lookup as requested:
        # - mini backend uses v19 prestashop_id
        # - cake backend uses v19 cl_prestashop_id
        if bcode == "mini":
            by_id = mm.feature_value_by_mini_ps.get(ps_v)
            if by_id:
                return by_id
        elif bcode == "cake":
            by_id = mm.feature_value_by_cake_ps.get(ps_v)
            if by_id:
                return by_id

    mm.warn(f"{label}: product.feature.values id={fv_odoo_id}: no v19 row for PrestaShop IDs in bindings.")
    return None


def map_feature_value_from_feature_line(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    mm: MasterMaps,
    feature_odoo_id: int,
    value_odoo_id: int,
    backend_ids: List[int],
    backend_id_to_code: Dict[int, str],
    cache_feat: Dict[Tuple[int, int], int],
    cache_val: Dict[Tuple[int, int], int],
    label: str,
) -> Optional[int]:
    """
    v14 `product.feature.line` stores Many2one to feature + value (internal ids differ between DBs).
    Resolve by comparing backend-specific PrestaShop IDs:

      - backend mini: v19 match `prestashop_id`
      - backend cake: v19 match `cl_prestashop_id`
    """

    def _get_ps_id(binding_model: str, cache: Dict[Tuple[int, int], int], odoo_id: int, backend_id: int) -> Optional[int]:
        key = (odoo_id, backend_id)
        if key in cache:
            return cache[key] or None
        rows = m14.execute_kw(
            db14,
            uid14,
            pwd,
            binding_model,
            "search_read",
            [[("odoo_id", "=", odoo_id), ("backend_id", "=", backend_id)]],
            {"fields": ["prestashop_id"], "limit": 1},
        )
        ps = rows and rows[0].get("prestashop_id")
        cache[key] = int(ps) if ps else 0
        return int(ps) if ps else None

    for backend_id in backend_ids:
        bcode = backend_id_to_code.get(int(backend_id), "")
        ps_f = _get_ps_id("prestashop.product.features", cache_feat, feature_odoo_id, backend_id)
        ps_v = _get_ps_id("prestashop.product.feature.values", cache_val, value_odoo_id, backend_id)
        if not ps_f or not ps_v:
            continue

        # Pair match first (most accurate)
        pair = mm.feature_value_pair.get((int(ps_f), int(ps_v)))
        if pair:
            return pair

        # Fallback: backend-specific value-id lookup
        if bcode == "mini":
            by_id = mm.feature_value_by_mini_ps.get(int(ps_v))
            if by_id:
                return by_id
        elif bcode == "cake":
            by_id = mm.feature_value_by_cake_ps.get(int(ps_v))
            if by_id:
                return by_id

    mm.warn(
        f"{label}: feature line (feature_id={feature_odoo_id}, value_id={value_odoo_id}) "
        "could not map to v19 using mini/cake PrestaShop IDs."
    )
    return None


def migrate_features_for_product(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    tmpl14_id: int,
    mm: MasterMaps,
    label: str,
) -> List[int]:
    """
    Map v14 features → v19 ``prestashop.feature.value`` ids via ``product.feature.line`` and connector bindings
    (Mini/Cake PrestaShop feature/value ids). Template ``ps_feature_value_ids`` is not read or migrated.
    """
    mapped_value_ids: List[int] = []
    backs = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.backend",
        "search_read",
        [[("code", "in", ["mini", "cake"])]],
        {"fields": ["id", "code"]},
    )
    backend_ids = [int(b["id"]) for b in backs]
    backend_id_to_code = {int(b["id"]): (b.get("code") or "") for b in backs}
    cache_feat: Dict[Tuple[int, int], int] = {}
    cache_val: Dict[Tuple[int, int], int] = {}

    if model_exists(m14, db14, uid14, pwd, "product.feature.line"):
        lines = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "product.feature.line",
            "search_read",
            [[("product_tmpl_id", "=", tmpl14_id)]],
            {"fields": ["feature_id", "feature_value_id"]},
        )
        for ln in lines or []:
            fid = m2o_id(ln.get("feature_id"))
            fvid = m2o_id(ln.get("feature_value_id"))
            if not fid or not fvid:
                continue
            mid = map_feature_value_from_feature_line(
                m14,
                db14,
                uid14,
                pwd,
                mm,
                fid,
                fvid,
                backend_ids,
                backend_id_to_code,
                cache_feat,
                cache_val,
                label,
            )
            if mid:
                mapped_value_ids.append(mid)
            else:
                mm.warn(f"{label}: feature line (feature_id={fid}, value_id={fvid}) could not map.")

    return list(dict.fromkeys(mapped_value_ids))


def write_v19_feature_lines(
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    pwd: str,
    mm: MasterMaps,
    tmpl19_id: int,
    value_ids19: List[int],
    label: str,
    dry_run: bool,
    replace: bool,
) -> None:
    """
    Preferred v19 sync: product.template.ps_feature_line_ids (product.feature.line).
    Uses mapped prestashop.feature.value ids, and sets feature_id from that value.
    """
    if not value_ids19:
        return
    if not model_exists(m19, db19, uid19, pwd, "product.feature.line"):
        mm.warn(f"{label}: v19 model product.feature.line missing; cannot sync feature lines.")
        return

    # Read feature_id for each value to construct valid lines.
    rows = m19.execute_kw(
        db19,
        uid19,
        pwd,
        "prestashop.feature.value",
        "read",
        [value_ids19],
        {"fields": ["id", "feature_id"]},
    )
    pairs: List[Tuple[int, int]] = []
    for r in rows:
        fid = m2o_id(r.get("feature_id"))
        if fid:
            pairs.append((fid, int(r["id"])))
    # Deduplicate pairs
    seen = set()
    pairs2: List[Tuple[int, int]] = []
    for p in pairs:
        if p not in seen:
            seen.add(p)
            pairs2.append(p)

    if replace and not dry_run:
        old = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.feature.line",
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if old:
            m19.execute_kw(db19, uid19, pwd, "product.feature.line", "unlink", [old])

    if dry_run:
        return
    seq = 10
    for fid, fvid in pairs2:
        m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.feature.line",
            "create",
            [
                {
                    "product_tmpl_id": tmpl19_id,
                    "sequence": seq,
                    "feature_id": fid,
                    "feature_value_id": fvid,
                }
            ],
        )
        seq += 10


def migrate_nutrition(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tmpl14_id: int,
    tmpl19_id: int,
    mm: MasterMaps,
    label: str,
    dry_run: bool,
) -> None:
    fg_nv14 = fields_get(m14, db14, uid14, pwd, "product.nutrition.value")
    line_fields = ["nutrition_id", "value"]
    if fg_nv14.get("prestashop_id"):
        line_fields.append("prestashop_id")
    if fg_nv14.get("prestashop_bind_ids"):
        line_fields.append("prestashop_bind_ids")

    fg_n14 = fields_get(m14, db14, uid14, pwd, "product.nutrition")

    lines = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.nutrition.value",
        "search_read",
        [[("product_id", "=", tmpl14_id)]],
        {"fields": line_fields},
    )
    if not lines:
        return

    line_ids = [int(l["id"]) for l in lines]
    val_bind_model = "prestashop.product.nutrition.value"
    bind_by_line = v14_bindings_by_odoo_id(
        m14,
        db14,
        uid14,
        pwd,
        val_bind_model,
        line_ids,
        reference_model="product.nutrition.value",
    )
    if fg_nv14.get("prestashop_bind_ids") and not model_exists(m14, db14, uid14, pwd, val_bind_model):
        mm.warn(
            f"{label}: v14 has prestashop_bind_ids on product.nutrition.value but model "
            f"{val_bind_model!r} is missing — cannot resolve line PrestaShop IDs."
        )

    nut_ids = sorted({m2o_id(l.get("nutrition_id")) for l in lines if m2o_id(l.get("nutrition_id"))})
    nut_bind_model = "prestashop.product.nutrition"
    bind_by_nut = v14_bindings_by_odoo_id(
        m14,
        db14,
        uid14,
        pwd,
        nut_bind_model,
        list(nut_ids),
        reference_model="product.nutrition",
    )
    if fg_n14.get("prestashop_bind_ids") and not model_exists(m14, db14, uid14, pwd, nut_bind_model):
        mm.warn(
            f"{label}: v14 has prestashop_bind_ids on product.nutrition but model "
            f"{nut_bind_model!r} is missing — cannot resolve nutrition master PrestaShop IDs."
        )

    backs = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.backend",
        "search_read",
        [[("code", "in", ["mini", "cake"])]],
        {"fields": ["id", "code"]},
    )
    backend_id_to_code: Dict[int, str] = {
        int(b["id"]): (b.get("code") or "").strip() for b in backs
    }

    read_n_fields = ["name"]
    if fg_n14.get("prestashop_id"):
        read_n_fields.append("prestashop_id")
    nrows: Dict[int, Dict[str, Any]] = {}
    if nut_ids:
        nread = m14.execute_kw(
            db14,
            uid14,
            pwd,
            "product.nutrition",
            "read",
            [list(nut_ids)],
            {"fields": read_n_fields},
        )
        for r in nread:
            nrows[int(r["id"])] = r

    fg_nv19 = fields_get(m19, db19, uid19, pwd, "product.nutrition.value")

    for line in lines:
        nid = m2o_id(line.get("nutrition_id"))
        if not nid:
            continue
        ps_m_master, ps_c_master = v14_mini_cake_prestashop_from_bindings(
            bind_by_nut.get(int(nid), []),
            backend_id_to_code,
            mm,
            label,
            f"product.nutrition id={nid}",
        )
        nrow = nrows.get(int(nid))
        if ps_m_master is None and fg_n14.get("prestashop_id") and nrow and nrow.get("prestashop_id"):
            ps_m_master = int(nrow["prestashop_id"])

        nut19: Optional[int] = None
        if ps_m_master:
            nut19 = mm.nutrition_by_ps.get(int(ps_m_master))
        if not nut19 and ps_c_master and mm.nutrition_by_cl_ps:
            nut19 = mm.nutrition_by_cl_ps.get(int(ps_c_master))
        if not nut19:
            mm.warn(
                f"{label}: product.nutrition id={nid} not mappable to v19 "
                f"(mini_ps={ps_m_master}, cake_ps={ps_c_master})."
            )
            continue
        if dry_run:
            continue
        existing = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.nutrition.value",
            "search",
            [[("product_id", "=", tmpl19_id), ("nutrition_id", "=", nut19)]],
            {"limit": 1},
        )
        nvals: Dict[str, Any] = {
            "product_id": tmpl19_id,
            "nutrition_id": nut19,
            "value": line.get("value") or 0.0,
        }

        lid = int(line["id"])
        ps_line_mini, ps_line_cake = v14_mini_cake_prestashop_from_bindings(
            bind_by_line.get(lid, []),
            backend_id_to_code,
            mm,
            label,
            f"product.nutrition.value line id={lid}",
        )

        if fg_nv14.get("prestashop_id") and line.get("prestashop_id"):
            if ps_line_mini is None:
                ps_line_mini = int(line["prestashop_id"])

        if ps_line_mini is not None and "prestashop_id" in fg_nv19:
            nvals["prestashop_id"] = ps_line_mini
        if ps_line_cake is not None and "cl_prestashop_id" in fg_nv19:
            nvals["cl_prestashop_id"] = ps_line_cake

        if existing:
            m19.execute_kw(db19, uid19, pwd, "product.nutrition.value", "write", [[existing[0]], nvals])
        else:
            m19.execute_kw(db19, uid19, pwd, "product.nutrition.value", "create", [nvals])


def map_portion_field(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    field_name: str,
    tmpl14: Dict[str, Any],
    mm: MasterMaps,
    label: str,
) -> Optional[int]:
    raw = tmpl14.get(field_name)
    pid = m2o_id(raw)
    if not pid:
        return None
    fg_p14 = fields_get(m14, db14, uid14, pwd, "product.nutrition.portion")
    read_fields = ["name"]
    if "prestashop_id" in fg_p14:
        read_fields.append("prestashop_id")
    if "cl_prestashop_id" in fg_p14:
        read_fields.append("cl_prestashop_id")
    prow = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.nutrition.portion",
        "read",
        [[pid]],
        {"fields": read_fields},
    )
    if not prow:
        return None
    mini_ps = prow[0].get("prestashop_id")
    cake_ps = prow[0].get("cl_prestashop_id")

    # Backend-aware mapping (same idea as feature values):
    if mini_ps:
        tgt = mm.portion_by_mini_ps.get(int(mini_ps))
        if tgt:
            return tgt
        mm.warn(f"{label}: portion prestashop_id={mini_ps} missing in v19.")
    if cake_ps:
        tgt = mm.portion_by_cake_ps.get(int(cake_ps))
        if tgt:
            return tgt
        mm.warn(f"{label}: portion cl_prestashop_id={cake_ps} missing in v19.")

    mm.warn(
        f"{label}: v14 {field_name} id={pid} has no mappable PrestaShop id "
        f"(prestashop_id={mini_ps}, cl_prestashop_id={cake_ps})."
    )
    return None


def uom_by_name(
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    pwd: str,
    name: str,
    mm: MasterMaps,
    label: str,
) -> Optional[int]:
    if not name:
        return None
    found = m19.execute_kw(
        db19, uid19, pwd, "uom.uom", "search", [[("name", "=", name)]], {"limit": 2}
    )
    if len(found) == 1:
        return found[0]
    mm.warn(f"{label}: uom.uom {name!r} not unique/absent on v19.")
    return None


def map_uom_id(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    mm: MasterMaps,
    uom_m2o: Any,
    label: str,
) -> Optional[int]:
    u14 = m2o_id(uom_m2o)
    if not u14:
        return None
    urow = m14.execute_kw(db14, uid14, pwd, "uom.uom", "read", [[u14]], {"fields": ["name"]})
    uname = urow and (urow[0].get("name") or "")
    if not uname:
        return None
    return uom_by_name(m19, db19, uid19, pwd, uname, mm, label)


def create_v19_product_template(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    mm: MasterMaps,
    fg14_all: Dict[str, Any],
    fg19_all: Dict[str, Any],
    blob: Dict[str, Any],
    label: str,
    dry_run: bool,
    v14_template_id: int,
    migrate_main_image: bool = True,
) -> Optional[int]:
    """
    Create a minimal `product.template` in v19 when no match exists.
    We only set fields that are safe + commonly required. Other data is written by the normal sync step.
    """
    vals: Dict[str, Any] = {}

    if V14_TEMPLATE_ID_FIELD in fg19_all and v14_template_id:
        vals[V14_TEMPLATE_ID_FIELD] = int(v14_template_id)

    # Always try to set these
    if "name" in fg19_all:
        vals["name"] = blob.get("name") or label
    if "default_code" in fg19_all and blob.get("default_code"):
        vals["default_code"] = blob.get("default_code")
    if "list_price" in fg19_all and blob.get("list_price") is not None:
        vals["list_price"] = blob.get("list_price")
    if "active" in fg19_all and blob.get("active") is not None:
        vals["active"] = blob.get("active")
    if "sale_ok" in fg19_all and blob.get("sale_ok") is not None:
        vals["sale_ok"] = blob.get("sale_ok")
    if "purchase_ok" in fg19_all and blob.get("purchase_ok") is not None:
        vals["purchase_ok"] = blob.get("purchase_ok")
    if "type" in fg19_all:
        mapped_type = map_v14_blob_product_template_type(blob)
        if mapped_type:
            vals["type"] = mapped_type

    # PrestaShop IDs / shop flags (used by other sync)
    if "prestashop_id" in fg19_all and blob.get("prestashop_id") is not None:
        vals["prestashop_id"] = blob.get("prestashop_id")
    if "cl_prestashop_id" in fg19_all and blob.get("cl_prestashop_id") is not None:
        vals["cl_prestashop_id"] = blob.get("cl_prestashop_id")
    if "is_minischoggi" in fg19_all and blob.get("prestashop_id"):
        vals["is_minischoggi"] = True
    if "is_cakelicious" in fg19_all and blob.get("cl_prestashop_id"):
        vals["is_cakelicious"] = True

    if migrate_main_image and "image_1920" in fg19_all and blob.get("image_1920"):
        vals["image_1920"] = blob["image_1920"]

    # Required UoM fields in Odoo are typically uom_id + uom_po_id
    if "uom_id" in fg19_all and "uom_id" in fg14_all and blob.get("uom_id"):
        u19 = map_uom_id(m14, db14, uid14, pwd, m19, db19, uid19, mm, blob.get("uom_id"), label)
        if u19:
            vals["uom_id"] = u19
            if "uom_po_id" in fg19_all:
                vals["uom_po_id"] = u19

    if dry_run:
        LOG.info("DRY-RUN would create v19 product.template for %s with keys=%s", label, sorted(vals.keys()))
        return None

    try:
        new_id = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.template",
            "create",
            [vals],
            {"context": PRODUCT_TEMPLATE_MIGRATION_CONTEXT},
        )
        LOG.info("Created v19 product.template id=%s for %s", new_id, label)
        return int(new_id)
    except Exception as e:
        mm.warn(f"{label}: failed to create v19 product.template: {e}")
        return None


def migrate_uom_pricing_lines(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tmpl14_id: int,
    tmpl19_id: int,
    mm: MasterMaps,
    label: str,
    dry_run: bool,
    replace: bool,
) -> None:
    """
    Copy v14 `uom_pricing_line_ids` → v19 `uom_pricing_line_ids` when the model exists.

    v19 model in addonsthomas19: `product.minischoggi.uom.pricing.line` with fields
    `quantity`, `product_uom_id` (price is computed from template.list_price).
    """
    if not model_exists(m19, db19, uid19, pwd, "product.minischoggi.uom.pricing.line"):
        return
    fg14 = fields_get(m14, db14, uid14, pwd, "product.template")
    fg19 = fields_get(m19, db19, uid19, pwd, "product.template")
    if "uom_pricing_line_ids" not in fg14 or "uom_pricing_line_ids" not in fg19:
        return

    line_model = "product.minischoggi.uom.pricing.line"
    if not model_exists(m14, db14, uid14, pwd, line_model):
        mm.warn(f"{label}: v14 has uom_pricing_line_ids but model {line_model} not found.")
        return

    lines14 = m14.execute_kw(
        db14,
        uid14,
        pwd,
        line_model,
        "search_read",
        [[("product_tmpl_id", "=", tmpl14_id)]],
        {"fields": ["quantity", "product_uom_id"]},
    )
    if not lines14:
        return

    if replace and not dry_run:
        old = m19.execute_kw(
            db19,
            uid19,
            pwd,
            line_model,
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if old:
            m19.execute_kw(db19, uid19, pwd, line_model, "unlink", [old])

    for ln in lines14:
        qty = ln.get("quantity") or 0.0
        u14 = m2o_id(ln.get("product_uom_id"))
        if not u14 or not qty:
            continue
        urow = m14.execute_kw(db14, uid14, pwd, "uom.uom", "read", [[u14]], {"fields": ["name"]})
        uname = urow and urow[0].get("name") or ""
        u19 = uom_by_name(m19, db19, uid19, pwd, uname, mm, label)
        if not u19:
            continue
        if dry_run:
            continue
        m19.execute_kw(
            db19,
            uid19,
            pwd,
            line_model,
            "create",
            [{"product_tmpl_id": tmpl19_id, "quantity": qty, "product_uom_id": u19}],
        )


def _gallery_binary_field_priority(probe: Dict[str, Any]) -> List[str]:
    """Ordered image field names on a gallery line model (best resolution first)."""
    out: List[str] = []
    for fname in ("image_1920", "image_1024", "image"):
        spec = probe.get(fname) or {}
        if fname in probe and spec.get("type") in ("binary", "image"):
            out.append(fname)
    return out


def _row_coalesce_gallery_image(row: Dict[str, Any], priority: List[str]) -> None:
    """Set ``image_1920`` from the first non-empty binary among ``priority`` fields."""
    for fname in priority:
        raw = row.get(fname)
        if raw:
            row["image_1920"] = raw
            return


def migrate_images(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tmpl14_id: int,
    tmpl19_id: int,
    fg14: Dict[str, Any],
    mm: MasterMaps,
    label: str,
    dry_run: bool,
    replace: bool,
    migrate_binary: bool,
) -> None:
    if not migrate_binary or not model_exists(m19, db19, uid19, pwd, "product.template.image"):
        return
    rows: List[Dict[str, Any]] = []

    if "image_ids" in fg14 and fg14["image_ids"].get("type") == "one2many":
        rel = fg14["image_ids"].get("relation")
        if rel and model_exists(m14, db14, uid14, pwd, rel):
            probe = m14.execute_kw(db14, uid14, pwd, rel, "fields_get", [[], {"attributes": ["type"]}])
            priority = _gallery_binary_field_priority(probe)
            if priority:
                fields = ["name", "sequence"] + priority
                for opt in ("prestashop_id", "cl_prestashop_image_id"):
                    if opt in probe:
                        fields.append(opt)
                rows = m14.execute_kw(
                    db14,
                    uid14,
                    pwd,
                    rel,
                    "search_read",
                    [[("product_tmpl_id", "=", tmpl14_id)]],
                    {"fields": fields},
                )
                for r in rows:
                    _row_coalesce_gallery_image(r, priority)

    if not rows and model_exists(m14, db14, uid14, pwd, "common.product.image.ept"):
        probe_ept = m14.execute_kw(
            db14, uid14, pwd, "common.product.image.ept", "fields_get", [[], {"attributes": ["type"]}]
        )
        priority_ept = _gallery_binary_field_priority(probe_ept)
        if priority_ept:
            fields = ["name", "sequence"] + priority_ept
            rows = m14.execute_kw(
                db14,
                uid14,
                pwd,
                "common.product.image.ept",
                "search_read",
                [[("template_id", "=", tmpl14_id)]],
                {"fields": fields},
            )
            for r in rows:
                _row_coalesce_gallery_image(r, priority_ept)

    if not rows:
        return

    if replace and not dry_run:
        old = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.template.image",
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if old:
            m19.execute_kw(db19, uid19, pwd, "product.template.image", "unlink", [old])

    for r in rows:
        if dry_run:
            continue
        vals = {
            "product_tmpl_id": tmpl19_id,
            "name": r.get("name"),
            "sequence": r.get("sequence") or 10,
            "image_1920": r.get("image_1920"),
        }
        if r.get("prestashop_id"):
            vals["prestashop_id"] = r["prestashop_id"]
        if r.get("cl_prestashop_image_id"):
            vals["cl_prestashop_image_id"] = r["cl_prestashop_image_id"]
        if vals.get("image_1920"):
            m19.execute_kw(db19, uid19, pwd, "product.template.image", "create", [vals])


def migrate_events(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tmpl14_id: int,
    tmpl19_id: int,
    mm: MasterMaps,
    label: str,
    dry_run: bool,
    replace: bool,
) -> None:
    if not (
        model_exists(m14, db14, uid14, pwd, "product.event")
        and model_exists(m19, db19, uid19, pwd, "product.event")
    ):
        return
    fg_ev14 = fields_get(m14, db14, uid14, pwd, "product.event")
    fg_ev19 = fields_get(m19, db19, uid19, pwd, "product.event")
    copy_fields: List[str] = []
    for fname, spec14 in fg_ev14.items():
        if fname in ("id", "product_tmpl_id"):
            continue
        if fname not in fg_ev19:
            continue
        spec19 = fg_ev19[fname]
        if spec19.get("readonly"):
            continue
        if should_skip_auto_field(fname, spec14) or should_skip_auto_field(fname, spec19):
            continue
        if spec14.get("type") != spec19.get("type"):
            continue
        copy_fields.append(fname)
    # Ensure event window fields migrate even if metadata oddities skipped them.
    for must in ("date_start", "date_end"):
        if (
            must in fg_ev14
            and must in fg_ev19
            and fg_ev14[must].get("type") == "datetime"
            and fg_ev19[must].get("type") == "datetime"
            and must not in copy_fields
            and not fg_ev19[must].get("readonly")
        ):
            copy_fields.append(must)

    if not copy_fields:
        mm.warn(f"{label}: product.event: no compatible scalar fields between v14 and v19.")
        return

    evs = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "product.event",
        "search_read",
        [[("product_tmpl_id", "=", tmpl14_id)]],
        {"fields": copy_fields},
    )
    if not evs:
        return
    if replace and not dry_run:
        old = m19.execute_kw(
            db19,
            uid19,
            pwd,
            "product.event",
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if old:
            m19.execute_kw(db19, uid19, pwd, "product.event", "unlink", [old])
    for ev in evs:
        if dry_run:
            continue
        vals: Dict[str, Any] = {"product_tmpl_id": tmpl19_id}
        for fname in copy_fields:
            if fname not in ev:
                continue
            vals[fname] = ev[fname]
        m19.execute_kw(db19, uid19, pwd, "product.event", "create", [vals])


def migrate_binding_rows_to_common_detail(
    m14: xmlrpc.client.ServerProxy,
    db14: str,
    uid14: int,
    pwd: str,
    m19: xmlrpc.client.ServerProxy,
    db19: str,
    uid19: int,
    tmpl14_id: int,
    tmpl19_id: int,
    mm: MasterMaps,
    label: str,
    dry_run: bool,
    replace: bool,
) -> None:
    line_model = "product.prestashop.common.detail.line"
    if not model_exists(m14, db14, uid14, pwd, "prestashop.product.template"):
        return
    if not model_exists(m19, db19, uid19, pwd, line_model):
        return

    code_to_scope = {"mini": "minischoggi", "cake": "cakelicious"}
    want_bind_fields = [
        "backend_id",
        "ps_name",
        "link_rewrite",
        "upc",
        "reference",
        "quantity",
        "description_short_html",
        "description_html",
        "always_available",
        "available_for_order",
        "sale_ok",
        "show_price",
        "on_sale",
        "date_add",
        "date_upd",
        "minimal_quantity",
        "additional_shipping_cost",
        "online_only",
        "available_now",
        "available_later",
        "available_date",
        "default_shop_id",
    ]
    bt_fields = fields_get(m14, db14, uid14, pwd, "prestashop.product.template")
    bind_fields_read = [f for f in want_bind_fields if f in bt_fields]

    binds = m14.execute_kw(
        db14,
        uid14,
        pwd,
        "prestashop.product.template",
        "search_read",
        [[("odoo_id", "=", tmpl14_id)]],
        {"fields": bind_fields_read or ["backend_id"]},
    )
    if not binds:
        return

    if replace and not dry_run:
        old = m19.execute_kw(
            db19,
            uid19,
            pwd,
            line_model,
            "search",
            [[("product_tmpl_id", "=", tmpl19_id)]],
        )
        if old:
            m19.execute_kw(db19, uid19, pwd, line_model, "unlink", [old])

    for b in binds:
        back = b.get("backend_id")
        code = None
        if isinstance(back, (list, tuple)):
            brow = m14.execute_kw(
                db14, uid14, pwd, "prestashop.backend", "read", [[back[0]]], {"fields": ["code"]}
            )
            code = brow and brow[0].get("code")
        scope = code_to_scope.get(code or "")
        if not scope:
            continue
        if dry_run:
            continue
        shop_field = (
            "ps_common_detail_minischoggi_ids"
            if scope == "minischoggi"
            else "ps_common_detail_cakelicious_ids"
        )
        fg = fields_get(m19, db19, uid19, pwd, "product.template")
        if shop_field not in fg:
            continue

        lm_fields = fields_get(m19, db19, uid19, pwd, line_model)
        vals_line: Dict[str, Any] = {
            "product_tmpl_id": tmpl19_id,
            "shop_scope": scope,
        }
        binding_to_line = {
            "ps_name": "ps_name",
            "link_rewrite": "friendly_url",
            "upc": "upc",
            "reference": "reference",
            "quantity": "computed_qty",
            "description_short_html": "description_short_html",
            "description_html": "description_html",
            "always_available": "always_available",
            "available_for_order": "available_for_order",
            "sale_ok": "sale_ok",
            "show_price": "show_price",
            "on_sale": "on_sale",
            "date_add": "date_add",
            "date_upd": "date_upd",
            "minimal_quantity": "minimal_quantity",
            "additional_shipping_cost": "additional_shipping_cost",
            "online_only": "online_only",
            "available_now": "available_now",
            "available_later": "available_later",
            "available_date": "available_date",
        }
        for bind_k, line_k in binding_to_line.items():
            if bind_k in b and line_k in lm_fields:
                vals_line[line_k] = b.get(bind_k)

        ds = m2o_id(b.get("default_shop_id"))
        if ds and "default_shop" in lm_fields:
            srow = m14.execute_kw(
                db14, uid14, pwd, "prestashop.shop", "read", [[ds]], {"fields": ["name"]}
            )
            vals_line["default_shop"] = srow and srow[0].get("name")

        m19.execute_kw(db19, uid19, pwd, line_model, "create", [vals_line])


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--odo14-url", default=DEFAULT_ODOO14["url"])
    parser.add_argument("--odo14-db", default=DEFAULT_ODOO14["db"])
    parser.add_argument("--odo19-url", default=DEFAULT_ODOO19["url"])
    parser.add_argument("--odo19-db", default=DEFAULT_ODOO19["db"])
    parser.add_argument("--user", default=DEFAULT_ODOO14["user"])
    parser.add_argument("--password", default=DEFAULT_ODOO14["password"])
    parser.add_argument(
        "--match-by",
        choices=("default_code", "prestashop_id", "both"),
        default="both",
    )
    parser.add_argument("--domain", default="[(1, '=', 1)]")
    parser.add_argument("--limit", type=int, default=0, help="Max products to attempt in this run (0 = unlimited).")
    parser.add_argument("--batch-size", type=int, default=5, help="Products per batch/run.")
    parser.add_argument(
        "--state-file",
        default=os.path.expanduser("~/Documents/minischoggi_product_migration_state.json"),
        help="Checkpoint file. Successful rows are never reprocessed.",
    )
    parser.add_argument("--reset-state", action="store_true", help="Ignore previous state and start from id=0.")
    parser.add_argument(
        "--continue-on-error",
        action="store_true",
        help="Continue processing the batch after a product fails (failed ids are tracked for retry).",
    )
    parser.add_argument(
        "--retry-failed",
        action="store_true",
        help="Before the normal batch, retry any failed ids recorded in the state file.",
    )
    parser.add_argument("--dry-run", action="store_true")
    parser.add_argument("--warnings-file", default="")
    parser.add_argument(
        "--migrate-main-image",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Copy product.template main image (image_1920) when present on both sides. Default: on.",
    )
    parser.add_argument(
        "--migrate-gallery-images",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Copy gallery images (product.template.image / common.product.image.ept). Default: on.",
    )
    parser.add_argument("--replace-gallery-images", action="store_true")
    parser.add_argument("--replace-events", action="store_true")
    parser.add_argument(
        "--migrate-uom-pricing-lines",
        action="store_true",
        help="Copy uom_pricing_line_ids (v14 → v19) when available.",
    )
    parser.add_argument("--replace-uom-pricing-lines", action="store_true")
    parser.add_argument(
        "--sync-feature-lines",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Write v19 ps_feature_line_ids (product.feature.line). Default: on.",
    )
    parser.add_argument("--replace-feature-lines", action="store_true")
    parser.add_argument(
        "--create-missing-products",
        action="store_true",
        default=True,
        help="Create v19 product.template when no match exists (default: on).",
    )
    parser.add_argument(
        "--no-create-missing-products",
        action="store_false",
        dest="create_missing_products",
        help="Disable v19 product.template creation.",
    )
    parser.add_argument(
        "--assume-v19-empty",
        action="store_true",
        help="Assume v19 has no products: skip matching and always create a new product.template per v14 record.",
    )
    parser.add_argument(
        "--prestashop-binding-lines",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Fill product.prestashop.common.detail.line from v14 prestashop.product.template. Default: on.",
    )
    parser.add_argument("--replace-binding-lines", action="store_true")
    parser.add_argument(
        "--sync-category-tab-lines",
        action=argparse.BooleanOptionalAction,
        default=True,
        help="Fill Minischoggi/Cakelicious category tab line models. Default: on.",
    )
    parser.add_argument("--replace-category-tab-lines", action="store_true")
    parser.add_argument("-v", "--verbose", action="store_true")
    args = parser.parse_args()

    logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO, format="%(levelname)s %(message)s")

    o14 = {"url": args.odo14_url, "db": args.odo14_db, "user": args.user, "password": args.password}
    o19 = {"url": args.odo19_url, "db": args.odo19_db, "user": args.user, "password": args.password}

    uid14, m14 = connect_odoo(o14)
    uid19, m19 = connect_odoo(o19)
    pwd = args.password

    state = {"last_id": 0, "failed_ids": []}
    if args.state_file and not args.reset_state:
        state = _load_state(args.state_file)
    elif args.reset_state and args.state_file and os.path.exists(args.state_file):
        # Don't delete automatically; just start fresh.
        state = {"last_id": 0, "failed_ids": []}

    try:
        domain = ast.literal_eval(args.domain)
    except (ValueError, SyntaxError) as e:
        LOG.error("Bad --domain: %s", e)
        return 1

    # Progress counters on source (v14)
    try:
        total_matching = m14.execute_kw(
            o14["db"], uid14, pwd, "product.template", "search_count", [domain]
        )
        remaining_matching = m14.execute_kw(
            o14["db"],
            uid14,
            pwd,
            "product.template",
            "search_count",
            [list(domain) + [("id", ">", int(state.get("last_id") or 0))]],
        )
        LOG.info(
            "v14 totals: total=%s, remaining_after_last_id=%s (last_id=%s).",
            total_matching,
            remaining_matching,
            state.get("last_id"),
        )
    except Exception as e:
        LOG.warning("Could not compute v14 progress counters: %s", e)

    fg14_all = fields_get(m14, o14["db"], uid14, pwd, "product.template")
    fg19_all = fields_get(m19, o19["db"], uid19, pwd, "product.template")

    mm = MasterMaps()
    LOG.info("Loading v19 PrestaShop master indexes…")
    load_v19_master_maps(m19, o19["db"], uid19, pwd, mm)

    batch_size = max(1, int(args.batch_size or 5))
    max_attempt = int(args.limit or 0)

    tmpl_ids: List[int] = []
    if args.retry_failed and state.get("failed_ids"):
        # Retry failed first (in this run), without touching last_id.
        tmpl_ids.extend([int(x) for x in state["failed_ids"][:batch_size]])

    if len(tmpl_ids) < batch_size:
        # Normal batch, strictly increasing ids; ensures \"already considered\" ids never reappear.
        remaining = batch_size - len(tmpl_ids)
        batch_domain = list(domain) + [("id", ">", int(state.get("last_id") or 0))]
        next_ids = m14.execute_kw(
            o14["db"],
            uid14,
            pwd,
            "product.template",
            "search",
            [batch_domain],
            {"order": "id asc", "limit": remaining},
        )
        tmpl_ids.extend(next_ids)

    if max_attempt and len(tmpl_ids) > max_attempt:
        tmpl_ids = tmpl_ids[:max_attempt]

    LOG.info(
        "Starting batch: %s products (last_id=%s, failed_pending=%s).",
        len(tmpl_ids),
        state.get("last_id"),
        len(state.get("failed_ids") or []),
    )

    read_extra = [
        "ps_categ_id",
        "ps_categ_ids",
        "cake_ps_categ_id",
        "cake_ps_categ_ids",
        "categ_id",
        "taxes_id",
        "supplier_taxes_id",
        "nutrition_portion_id",
        "price_portion_id",
        "src_uom_id",
        "line_feature_ids",
        "manufacturer",
    ]

    updated = skipped = failed = 0
    total_in_batch = len(tmpl_ids)
    processed_so_far = 0
    for tid in tmpl_ids:
        try:
            processed_so_far += 1
            pending_in_batch = total_in_batch - processed_so_far

            if V14_TEMPLATE_ID_FIELD in fg19_all:
                already_migrated = m19.execute_kw(
                    o19["db"],
                    uid19,
                    pwd,
                    "product.template",
                    "search",
                    [[(V14_TEMPLATE_ID_FIELD, "=", int(tid))]],
                    {"limit": 1},
                )
                if already_migrated:
                    skipped += 1
                    state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
                    _save_state(args.state_file, state)
                    LOG.info(
                        "Skip v14 product.template id=%s: already migrated (v19 id=%s). Batch %s/%s, %s pending.",
                        tid,
                        already_migrated[0],
                        processed_so_far,
                        total_in_batch,
                        pending_in_batch,
                    )
                    continue

            fields_read = sorted(set(fg14_all.keys()) | set(read_extra))
            tmpl14_list = m14.execute_kw(
                o14["db"],
                uid14,
                pwd,
                "product.template",
                "read",
                [[tid]],
                {"fields": fields_read, "context": V14_TEMPLATE_READ_CONTEXT},
            )
            if not tmpl14_list:
                skipped += 1
                # Considered, move checkpoint.
                state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
                _save_state(args.state_file, state)
                LOG.info(
                    "Batch progress: %s/%s processed, %s pending in this batch.",
                    processed_so_far,
                    total_in_batch,
                    pending_in_batch,
                )
                continue
            tmpl14 = tmpl14_list[0]
            blob = normalize_thomas_blob(tmpl14)
            coalesce_template_main_image(blob, fg14_all)
            label = blob.get("default_code") or blob.get("name") or f"id={tid}"
            enrich_product_template_from_prestashop_bindings(
                m14, o14["db"], uid14, pwd, tid, blob, mm, label
            )

            tmpl19_ids: List[int] = []
            if not args.assume_v19_empty:
                if args.match_by in ("default_code", "both") and blob.get("default_code"):
                    tmpl19_ids = m19.execute_kw(
                        o19["db"],
                        uid19,
                        pwd,
                        "product.template",
                        "search",
                        [[("default_code", "=", blob["default_code"])]],
                        {"limit": 2},
                    )
                if not tmpl19_ids and args.match_by in ("prestashop_id", "both"):
                    psid = blob.get("prestashop_id")
                    if psid:
                        tmpl19_ids = m19.execute_kw(
                            o19["db"],
                            uid19,
                            pwd,
                            "product.template",
                            "search",
                            [[("prestashop_id", "=", psid)]],
                            {"limit": 2},
                        )
                    if (
                        not tmpl19_ids
                        and blob.get("cl_prestashop_id")
                        and "cl_prestashop_id" in fg19_all
                    ):
                        tmpl19_ids = m19.execute_kw(
                            o19["db"],
                            uid19,
                            pwd,
                            "product.template",
                            "search",
                            [[("cl_prestashop_id", "=", blob["cl_prestashop_id"])]],
                            {"limit": 2},
                        )
            if len(tmpl19_ids) != 1:
                # If not found, create. If multiple matches, skip.
                if (args.assume_v19_empty or len(tmpl19_ids) == 0) and args.create_missing_products:
                    new_id = create_v19_product_template(
                        m14,
                        o14["db"],
                        uid14,
                        pwd,
                        m19,
                        o19["db"],
                        uid19,
                        mm,
                        fg14_all,
                        fg19_all,
                        blob,
                        label,
                        args.dry_run,
                        tid,
                        migrate_main_image=args.migrate_main_image,
                    )
                    if new_id:
                        tmpl19_id = new_id
                    else:
                        mm.warn(f"{label}: v19 template not found and creation failed/skipped.")
                        skipped += 1
                        state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
                        _save_state(args.state_file, state)
                        LOG.info(
                            "Batch progress: %s/%s processed, %s pending in this batch.",
                            processed_so_far,
                            total_in_batch,
                            pending_in_batch,
                        )
                        continue
                else:
                    mm.warn(f"{label}: v19 template match count={len(tmpl19_ids)}.")
                    skipped += 1
                    state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
                    _save_state(args.state_file, state)
                    LOG.info(
                        "Batch progress: %s/%s processed, %s pending in this batch.",
                        processed_so_far,
                        total_in_batch,
                        pending_in_batch,
                    )
                    continue
            else:
                tmpl19_id = tmpl19_ids[0]

            vals = copy_auto_scalars(blob, fg14_all, fg19_all)

            # Always persist the v14 product name verbatim (see V14_TEMPLATE_READ_CONTEXT + PRODUCT_TEMPLATE_MIGRATION_CONTEXT).
            if "name" in fg19_all and "name" in blob:
                vals["name"] = blob["name"]

            if V14_TEMPLATE_ID_FIELD in fg19_all:
                vals[V14_TEMPLATE_ID_FIELD] = int(tid)

            if "ps_category_ids" in fg19_all:
                mini_cats = merge_categories_mini(m14, o14["db"], uid14, pwd, blob, mm, label)
                if mini_cats:
                    vals["ps_category_ids"] = [(6, 0, mini_cats)]

            # Internal accounting category: map v14 `categ_id` by Mini PS id / name; else first Mini PS category.
            if "categ_id" in fg19_all:
                mc: Optional[int] = None
                if blob.get("categ_id"):
                    mc = map_internal_categ_id(
                        m14,
                        o14["db"],
                        uid14,
                        pwd,
                        m19,
                        o19["db"],
                        uid19,
                        blob.get("categ_id"),
                        mm,
                        label,
                    )
                if mc:
                    vals["categ_id"] = mc
                elif vals.get("ps_category_ids") and "categ_id" not in vals:
                    vals["categ_id"] = int(vals["ps_category_ids"][0][2][0])

            if "taxes_id" in fg19_all and blob.get("taxes_id"):
                tx_ids = map_account_taxes_m2m(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    blob.get("taxes_id"),
                    mm,
                    label,
                    "taxes_id",
                )
                if tx_ids:
                    vals["taxes_id"] = [(6, 0, tx_ids)]

            if "supplier_taxes_id" in fg19_all and blob.get("supplier_taxes_id"):
                stx_ids = map_account_taxes_m2m(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    blob.get("supplier_taxes_id"),
                    mm,
                    label,
                    "supplier_taxes_id",
                )
                if stx_ids:
                    vals["supplier_taxes_id"] = [(6, 0, stx_ids)]

            apply_res_currency_many2one_fields(
                blob,
                fg14_all,
                fg19_all,
                vals,
                m14,
                o14["db"],
                uid14,
                pwd,
                m19,
                o19["db"],
                uid19,
                mm,
                label,
            )

            if "cl_ps_category_ids" in fg19_all:
                cake_cats = merge_categories_cake(m14, o14["db"], uid14, pwd, blob, mm, label)
                if cake_cats:
                    vals["cl_ps_category_ids"] = [(6, 0, cake_cats)]

            # Do NOT sync PrestaShop Brand (Manufacturer) in this script.
            # Instead, sync the real manufacturer partner to the product.
            if "manufacturer_id" in fg19_all and blob.get("manufacturer"):
                mid19 = map_manufacturer_partner(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    mm,
                    blob.get("manufacturer"),
                    label,
                )
                if mid19:
                    vals["manufacturer_id"] = mid19

            # Auto-flag which shops this product belongs to (v19 expects these flags for cron exports).
            if "is_minischoggi" in fg19_all:
                has_mini_id = bool(blob.get("prestashop_id"))
                if has_mini_id:
                    vals["is_minischoggi"] = True
            if "is_cakelicious" in fg19_all:
                has_cake_id = bool(blob.get("cl_prestashop_id"))
                if has_cake_id:
                    vals["is_cakelicious"] = True

            if args.migrate_main_image and "image_1920" in fg19_all and blob.get("image_1920"):
                vals["image_1920"] = blob["image_1920"]

            for pf in ("nutrition_portion_id", "price_portion_id"):
                if pf in fg19_all:
                    p19 = map_portion_field(m14, o14["db"], uid14, pwd, pf, blob, mm, label)
                    vals[pf] = p19 if p19 else False

            if "src_uom_id" in fg19_all and blob.get("src_uom_id"):
                uid_u = m2o_id(blob["src_uom_id"])
                if uid_u:
                    urow = m14.execute_kw(
                        o14["db"], uid14, pwd, "uom.uom", "read", [[uid_u]], {"fields": ["name"]}
                    )
                    mapped_u = uom_by_name(
                        m19, o19["db"], uid19, pwd, urow and urow[0].get("name") or "", mm, label
                    )
                    vals["src_uom_id"] = mapped_u or False

            # v19 sync should use ps_feature_line_ids (O2M) not ps_feature_value_ids (M2M)
            value_ids19: List[int] = []
            if "ps_feature_line_ids" in fg19_all:
                fv_ids = migrate_features_for_product(
                    m14, o14["db"], uid14, pwd, tid, mm, label
                )
                value_ids19 = fv_ids or []

            if args.dry_run:
                LOG.debug("DRY-RUN %s → v19 id=%s keys=%s", label, tmpl19_id, sorted(vals.keys()))
            elif vals:
                # Never allow invalid categ_id payloads (must be an int).
                if "categ_id" in vals and not isinstance(vals["categ_id"], int):
                    mm.warn(f"{label}: dropping invalid categ_id value {vals['categ_id']!r} (must be int).")
                    vals.pop("categ_id", None)
                # Defensive: strip any list/tuple value that is not a valid ORM command.
                # XML-RPC read() returns many2one fields as [id, "Display Name"]. If the
                # field type differs between v14 and v19 (e.g. many2one in v14 but not in
                # fg19_all), copy_auto_scalars lets it through and Odoo 19's domain
                # optimizer crashes with "unhashable type: list" when stock.move tries to
                # build an OrderedSet from the value.  We therefore guard ALL list/tuple
                # values here, not just fields declared many2one in fg19_all.
                for k in list(vals.keys()):
                    v = vals[k]
                    if not isinstance(v, (list, tuple)):
                        continue
                    f19_type = (fg19_all.get(k) or {}).get("type")
                    # many2many ORM commands are (int, int, list) triples — leave those alone.
                    if f19_type == "many2many":
                        continue
                    # many2one: unwrap [id, display_name] → plain int.
                    if f19_type == "many2one":
                        if v and isinstance(v[0], int):
                            vals[k] = int(v[0])
                        else:
                            mm.warn(f"{label}: dropping invalid many2one {k}={v!r} (must be int id).")
                            vals.pop(k, None)
                        continue
                    # Any other field carrying a bare list is invalid for write() — drop it.
                    mm.warn(
                        f"{label}: dropping unexpected list value for field {k!r} "
                        f"(v19 type={f19_type!r}): {v!r}"
                    )
                    vals.pop(k, None)
                # Defensive: validate selection field values against v19's allowed keys.
                # Selection options can differ between versions (e.g. 'manual_periodic'
                # was removed from product.template.valuation in Odoo 17+). Sending a
                # stale key raises ValueError deep inside Odoo's ORM cache conversion.
                for k in list(vals.keys()):
                    f19 = fg19_all.get(k) or {}
                    if f19.get("type") != "selection":
                        continue
                    selection = f19.get("selection") or []
                    # selection is a list of [key, label] pairs from fields_get
                    valid_keys = {s[0] for s in selection if isinstance(s, (list, tuple)) and s}
                    if not valid_keys:
                        # Can't validate (empty or unexpected format) — leave as-is
                        continue
                    v = vals[k]
                    if v is False or v is None:
                        continue
                    if v not in valid_keys:
                        mm.warn(
                            f"{label}: dropping invalid selection value for {k!r}: "
                            f"{v!r} not in v19 choices {sorted(valid_keys)}"
                        )
                        vals.pop(k, None)
                normalize_product_template_valuation(vals, mm, label)
                normalize_product_template_ps_type(vals, mm, label)
                normalize_product_template_type(vals, mm, label)
                # Defensive: ensure we never write on multiple templates at once.
                if isinstance(tmpl19_id, (list, tuple)):
                    if len(tmpl19_id) == 1:
                        tmpl19_id = int(tmpl19_id[0])
                    else:
                        raise ValueError(f"{label}: tmpl19_id is not singleton: {tmpl19_id!r}")
                tmpl19_id = int(tmpl19_id)
                m19.execute_kw(
                    o19["db"],
                    uid19,
                    pwd,
                    "product.template",
                    "write",
                    [[tmpl19_id], vals],
                    {"context": PRODUCT_TEMPLATE_MIGRATION_CONTEXT},
                )

            if args.sync_feature_lines and "ps_feature_line_ids" in fg19_all:
                write_v19_feature_lines(
                    m19,
                    o19["db"],
                    uid19,
                    pwd,
                    mm,
                    tmpl19_id,
                    value_ids19,
                    label,
                    args.dry_run,
                    args.replace_feature_lines,
                )

            mini_cats_final = vals.get("ps_category_ids") and vals["ps_category_ids"][0][2] or merge_categories_mini(
                m14, o14["db"], uid14, pwd, blob, mm, label
            )
            cake_cats_final = vals.get("cl_ps_category_ids") and vals["cl_ps_category_ids"][0][2] or merge_categories_cake(
                m14, o14["db"], uid14, pwd, blob, mm, label
            )

            if args.sync_category_tab_lines:
                sync_tab_lines(
                    m19,
                    o19["db"],
                    uid19,
                    pwd,
                    mm,
                    "product.prestashop.category.minischoggi.line",
                    tmpl19_id,
                    mini_cats_final,
                    args.dry_run,
                    args.replace_category_tab_lines,
                    label,
                )
                sync_tab_lines(
                    m19,
                    o19["db"],
                    uid19,
                    pwd,
                    mm,
                    "product.prestashop.category.cakelicious.line",
                    tmpl19_id,
                    cake_cats_final,
                    args.dry_run,
                    args.replace_category_tab_lines,
                    label,
                )

            migrate_nutrition(
                m14, o14["db"], uid14, pwd, m19, o19["db"], uid19, tid, tmpl19_id, mm, label, args.dry_run
            )

            if args.migrate_uom_pricing_lines:
                migrate_uom_pricing_lines(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    tid,
                    tmpl19_id,
                    mm,
                    label,
                    args.dry_run,
                    args.replace_uom_pricing_lines,
                )

            if args.migrate_gallery_images:
                migrate_images(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    tid,
                    tmpl19_id,
                    fg14_all,
                    mm,
                    label,
                    args.dry_run,
                    args.replace_gallery_images,
                    migrate_binary=True,
                )

            migrate_events(
                m14,
                o14["db"],
                uid14,
                pwd,
                m19,
                o19["db"],
                uid19,
                tid,
                tmpl19_id,
                mm,
                label,
                args.dry_run,
                args.replace_events,
            )

            if args.prestashop_binding_lines:
                migrate_binding_rows_to_common_detail(
                    m14,
                    o14["db"],
                    uid14,
                    pwd,
                    m19,
                    o19["db"],
                    uid19,
                    tid,
                    tmpl19_id,
                    mm,
                    label,
                    args.dry_run,
                    args.replace_binding_lines,
                )

            updated += 1
            # Success: advance checkpoint and clear from failed list if present.
            state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
            state["failed_ids"] = [x for x in (state.get("failed_ids") or []) if int(x) != int(tid)]
            _save_state(args.state_file, state)
            LOG.info(
                "Batch progress: %s/%s processed, %s pending in this batch.",
                processed_so_far,
                total_in_batch,
                pending_in_batch,
            )

        except Exception as e:
            failed += 1
            LOG.exception("FAILED product.template id=%s: %s", tid, e)
            # Don't mark as successful, but track it for retry.
            failed_ids = set(int(x) for x in (state.get("failed_ids") or []))
            failed_ids.add(int(tid))
            state["failed_ids"] = sorted(failed_ids)
            # Only advance checkpoint if we're continuing; otherwise keep it so rerun retries this id.
            if args.continue_on_error:
                state["last_id"] = max(int(state.get("last_id") or 0), int(tid))
                _save_state(args.state_file, state)
                LOG.info(
                    "Batch progress: %s/%s processed, %s pending in this batch.",
                    processed_so_far,
                    total_in_batch,
                    pending_in_batch,
                )
                continue
            _save_state(args.state_file, state)
            raise

    processed_this_run = updated + skipped + failed
    LOG.info(
        "Finished batch. total_in_batch=%s, processed=%s (updated=%s skipped=%s failed=%s).",
        len(tmpl_ids),
        processed_this_run,
        updated,
        skipped,
        failed,
    )
    # Recompute remaining after finishing (uses updated checkpoint).
    try:
        remaining_now = m14.execute_kw(
            o14["db"],
            uid14,
            pwd,
            "product.template",
            "search_count",
            [list(domain) + [("id", ">", int(state.get("last_id") or 0))]],
        )
        total_now = m14.execute_kw(
            o14["db"], uid14, pwd, "product.template", "search_count", [domain]
        )
        LOG.info(
            "Progress: total=%s, processed_checkpointed=%s, remaining=%s (last_id=%s).",
            total_now,
            total_now - remaining_now,
            remaining_now,
            state.get("last_id"),
        )
    except Exception as e:
        LOG.warning("Could not compute final progress counters: %s", e)

    if mm.warnings and args.warnings_file:
        with open(args.warnings_file, "a", encoding="utf-8") as fh:
            for w in mm.warnings:
                fh.write(w + "\n")
        LOG.info("Wrote %s warnings to %s", len(mm.warnings), args.warnings_file)

    return 0


if __name__ == "__main__":
    sys.exit(main())
