"""
Typkonvertierung für CSV-Zellen gemäß type_conversions-JSON (Issue #21).

Locale-robust: dieselbe Vorlage kann Exporte mit wechselndem Datumsformat oder
Dezimaltrenner verarbeiten, wenn flexible oder auto-Optionen gesetzt sind.
"""

from __future__ import annotations

import datetime as dt
import re
from decimal import Decimal, InvalidOperation
from typing import Any, Mapping, Sequence

from dateutil import parser as dateutil_parser

from csv_parser.core import normalize_header_for_signature
from csv_parser.field_units import factor_source_to_canonical

# Alias → strptime (JSON in Kleinbuchstaben)
DATE_FORMAT_STRPTIME: dict[str, str] = {
    "yyyy-mm-dd": "%Y-%m-%d",
    "mm/dd/yyyy": "%m/%d/%Y",
    "dd/mm/yyyy": "%d/%m/%Y",
    "dd.mm.yyyy": "%d.%m.%Y",
    "dd.mm.yyyy hh:mm": "%d.%m.%Y %H:%M",
    "dd.mm.yyyy HH:MM": "%d.%m.%Y %H:%M",
    "yyyy-mm-dd hh:mm:ss": "%Y-%m-%d %H:%M:%S",
    "yyyy-mm-dd HH:MM:SS": "%Y-%m-%d %H:%M:%S",
}

TIME_FORMAT_STRPTIME: dict[str, str] = {
    "HH:MM": "%H:%M",
    "HH:MM:SS": "%H:%M:%S",
}

# Wenn flexible: zusätzliche strptime-Versuche (ungefähr häufig → seltener)
_STRPTIME_FALLBACK_DATES: list[str] = [
    "%Y-%m-%d",
    "%d.%m.%Y",
    "%d.%m.%y",
    "%d/%m/%Y",
    "%m/%d/%Y",
    "%Y/%m/%d",
    "%Y%m%d",
]
_STRPTIME_FALLBACK_DATETIME: list[str] = [
    "%Y-%m-%d",
    "%Y-%m-%d %H:%M:%S",
    "%Y-%m-%d %H:%M",
    "%d.%m.%Y %H:%M:%S",
    "%d.%m.%Y %H:%M",
    "%d.%m.%y %H:%M:%S",
    "%d.%m.%y %H:%M",
    "%Y-%m-%dT%H:%M:%S",
    "%Y-%m-%dT%H:%M:%SZ",
    "%Y-%m-%dT%H:%M:%S%z",
]


def _normalize_num_token(raw: str) -> str:
    return re.sub(r"[\s\u00a0\u202f]", "", raw.strip())


def _parse_float_auto(s: str) -> float:
    """
    Heuristik ohne festes Locale: Punkt/Komma als Tausender vs. Dezimal,
    basierend auf der letzten erkannten Trennstelle und Gruppierung.

    Apple Health u. a. liefern berechnete Mittelwerte mit vielen Nachkommastellen
    (z. B. «96.874937…») und Energie als «596.668904…» — dabei ist der Punkt
    immer Dezimaltrenner. Früher wurden lange Nachkommateile fälschlich so
    behandelt, dass der Punkt entfernt wurde (Tausender-Heuristik).
    """
    raw = s
    s = _normalize_num_token(s)
    if not s or s in ("-", "—", "–"):
        raise ValueError("leer")
    neg = False
    if s.startswith("(") and s.endswith(")"):
        neg = True
        s = s[1:-1].strip()
    if s.startswith("-"):
        neg = not neg
        s = s[1:]
    elif s.startswith("+"):
        s = s[1:]

    last_comma = s.rfind(",")
    last_dot = s.rfind(".")

    if last_comma >= 0 and last_dot >= 0:
        if last_comma > last_dot:
            s = s.replace(".", "").replace(",", ".")
        else:
            s = s.replace(",", "")
    elif last_comma >= 0:
        parts = s.split(",")
        if len(parts) == 2:
            left, right = parts[0], parts[1]
            if not right:
                raise ValueError("leer")
            left_digits = left.replace(".", "")
            # Langer Nachkommateil → Dezimalkomma; «1.234,56»-Fälle oben mit Punkt+Komma
            if len(right) > 3 or len(right) <= 2:
                s = left_digits + "." + right.replace(".", "")
            elif len(right) == 3 and len(left_digits) <= 3:
                s = left_digits + right
            else:
                s = left_digits + "." + right.replace(".", "")
        else:
            s = s.replace(",", "")
    elif last_dot >= 0:
        parts = s.split(".")
        if len(parts) == 2:
            left, right = parts[0], parts[1]
            if not right:
                raise ValueError("leer")
            left_digits = left.replace(",", "")
            # Genau ein Punkt: viele Nachkommastellen → Apple/US-Dezimalpunkt (nicht „.“ streichen)
            if len(right) > 3 or len(right) <= 2:
                s = left_digits + "." + right
            elif len(right) == 3:
                if len(left_digits) == 1 and left_digits != "0" and left_digits.isdigit():
                    s = left_digits + right
                else:
                    s = left_digits + "." + right
        elif len(parts) > 2:
            if len(parts[-1]) <= 2:
                s = "".join(parts[:-1]) + "." + parts[-1]
            else:
                s = "".join(parts)
        else:
            s = s.replace(".", "")

    try:
        v = float(Decimal(s))
    except (InvalidOperation, ValueError) as e:
        raise ValueError(f"Zahl nicht parsbar: {raw!r}") from e
    return -v if neg else v


def _parse_float(raw: str, decimal_sep: str) -> float:
    s = _normalize_num_token(raw)
    if not s:
        raise ValueError("leer")
    if "." in s and "," in s:
        return _parse_float_auto(s)
    if decimal_sep == ",":
        if "," not in s and "." in s:
            return _parse_float_auto(s)
        s = s.replace(".", "").replace(",", ".")
    else:
        if "," in s and "." not in s:
            return _parse_float_auto(s)
        s = s.replace(",", "")
    return float(Decimal(s))


def _float_from_spec(raw: str, spec: Mapping[str, Any]) -> float:
    dec = spec.get("decimal_separator", ".")
    flexible = bool(spec.get("flexible"))
    if dec in (None, "auto"):
        return _parse_float_auto(raw)
    try:
        return _parse_float(raw, str(dec))
    except (InvalidOperation, ValueError):
        if flexible:
            return _parse_float_auto(raw)
        raise


def _resolve_strptime_pattern(fmt_key: str) -> str | None:
    k = fmt_key.strip()
    if k.startswith("%"):
        return k
    return DATE_FORMAT_STRPTIME.get(k.lower())


def _collect_strptime_date_formats(spec: Mapping[str, Any], *, for_datetime: bool) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []

    def add(fmt_key: str) -> None:
        p = _resolve_strptime_pattern(fmt_key)
        if p and p not in seen:
            seen.add(p)
            out.append(p)
            if not for_datetime and p.endswith(" %H:%M") and "%H:%M:%S" not in p:
                p2 = p + ":%S"
                if p2 not in seen:
                    seen.add(p2)
                    out.append(p2)
            elif for_datetime and p.endswith(":%S"):
                # z. B. Apple Health „2026-04-09 16:48“ ohne Sekunden
                p_short = p[:-3]
                if p_short not in seen:
                    seen.add(p_short)
                    out.append(p_short)

    primary = spec.get("format")
    if primary:
        add(str(primary))
    extra = spec.get("formats")
    if isinstance(extra, Sequence) and not isinstance(extra, (str, bytes)):
        for item in extra:
            if item:
                add(str(item))

    if bool(spec.get("flexible")):
        for p in _STRPTIME_FALLBACK_DATETIME if for_datetime else _STRPTIME_FALLBACK_DATES:
            if p not in seen:
                seen.add(p)
                out.append(p)
    return out


def _try_strptime(s: str, patterns: Sequence[str]) -> dt.datetime | None:
    for pat in patterns:
        try:
            return dt.datetime.strptime(s, pat)
        except ValueError:
            continue
    return None


def _try_strptime_trim_time(s: str, patterns: Sequence[str]) -> dt.datetime | None:
    head = s.split(maxsplit=1)[0].strip() if s else ""
    if head and head != s:
        hit = _try_strptime(head, patterns)
        if hit:
            return hit
    return _try_strptime(s, patterns)


def _normalize_locale_date_months(s: str) -> str:
    """
    Omron Connect / Berichte: «10 Apr. 2026», «31 März 2026» — ohne DE→EN scheitert dateutil.
    """
    if not s:
        return s
    out = s
    for pat, rep in (
        (r"März", "March"),
        (r"Maerz", "March"),
        (r"Januar", "January"),
        (r"Februar", "February"),
        (r"Oktober", "October"),
        (r"Dezember", "December"),
        (r"Juni", "June"),
        (r"Juli", "July"),
        (r"\bMai\b", "May"),
    ):
        out = re.sub(pat, rep, out, flags=re.IGNORECASE)
    return out


def _dateutil_parse(s: str, spec: Mapping[str, Any]) -> dt.datetime | None:
    s_trim = s.strip()
    dayfirst_opt = spec.get("dayfirst")
    # ISO YYYY-MM-DD: dayfirst=True vertauscht Monat/Tag (09.04. → 04.09.)
    iso_ymd_prefix = bool(re.match(r"^\d{4}-\d{2}-\d{2}(\D|$)", s_trim))
    tries: list[bool | None]
    if dayfirst_opt is True:
        tries = [True]
    elif dayfirst_opt is False:
        tries = [False]
    else:
        tries = [False, True] if iso_ymd_prefix else [True, False]
    for df in tries:
        try:
            return dateutil_parser.parse(s_trim, dayfirst=df)
        except (ValueError, TypeError, OverflowError):
            continue
    return None


def _parse_date_typed(s: str, spec: Mapping[str, Any]) -> dt.date | dt.datetime:
    extract = spec.get("extract", "date_only")
    s0 = _normalize_locale_date_months(s.strip())
    patterns = _collect_strptime_date_formats(spec, for_datetime=False)
    part = _try_strptime_trim_time(s0, patterns) if patterns else None
    if part is None:
        part = _try_strptime(s0, _collect_strptime_date_formats(spec, for_datetime=True))
    if part is None and (bool(spec.get("flexible")) or spec.get("formats")):
        part = _dateutil_parse(s0, spec)
    if part is None:
        merged: dict[str, Any] = {**dict(spec), "flexible": True}
        if "dayfirst" not in merged:
            merged["dayfirst"] = True
        part = _dateutil_parse(s0, merged)
    if part is None:
        fmt_key = str(spec.get("format", ""))
        raise ValueError(f"Datum nicht parsbar: {fmt_key} / {s!r}")
    if extract == "date_only":
        return part.date()
    return part


def _parse_datetime_typed(s: str, spec: Mapping[str, Any]) -> dt.datetime:
    s0 = _normalize_locale_date_months(s.strip())
    patterns = _collect_strptime_date_formats(spec, for_datetime=True)
    part = _try_strptime(s0, patterns)
    if part is None and (bool(spec.get("flexible")) or spec.get("formats")):
        du = _dateutil_parse(s0, spec)
        if du:
            part = du
    if part is None:
        merged: dict[str, Any] = {**dict(spec), "flexible": True}
        if "dayfirst" not in merged:
            merged["dayfirst"] = True
        du = _dateutil_parse(s0, merged)
        if du:
            part = du
    if part is None:
        fmt_key = str(spec.get("format", ""))
        raise ValueError(f"Datetime nicht parsbar: {fmt_key} / {s!r}")
    return part


def _parse_time_typed(s: str, spec: Mapping[str, Any]) -> dt.time:
    patterns: list[str] = []
    seen: set[str] = set()
    primary = spec.get("format")
    if primary:
        fk = str(primary)
        p = TIME_FORMAT_STRPTIME.get(fk, _resolve_strptime_pattern(fk) or fk)
        if p not in seen:
            seen.add(p)
            patterns.append(p)
    extra = spec.get("formats")
    if isinstance(extra, Sequence) and not isinstance(extra, (str, bytes)):
        for item in extra:
            if not item:
                continue
            p = TIME_FORMAT_STRPTIME.get(str(item), str(item))
            if p not in seen:
                seen.add(p)
                patterns.append(p)
    if bool(spec.get("flexible")):
        for p in ("%H:%M:%S", "%H:%M"):
            if p not in seen:
                seen.add(p)
                patterns.append(p)
    part = _try_strptime(s.strip(), patterns)
    if part is None:
        raise ValueError(f"Zeit nicht parsbar: {s!r}")
    return part.time()


def _parse_int(raw: str, spec: Mapping[str, Any]) -> int:
    s = raw.strip()
    if bool(spec.get("flexible")) or spec.get("thousands_separator") == "auto":
        s2 = _normalize_num_token(s)
        if not s2 or s2 in ("-", "—", "–"):
            raise ValueError("leer")
        # EU-Dezimal (z. B. Apple DE «37,26» für HRV) — nicht alle Ziffern konkatenieren (würde 3726 → CHECK).
        if "," in s2 or "." in s2:
            try:
                fv = _parse_float_auto(s2)
                return int(round(fv))
            except (ValueError, InvalidOperation):
                pass
        neg = s2.startswith("-")
        body = s2[1:] if neg else s2
        digits = re.sub(r"\D", "", body)
        if not digits:
            raise ValueError("leer")
        v = int(digits)
        return -v if neg else v
    # Ohne flexible: «108.0» / «96,8» trotzdem als Zahl mit Nachkommastellen
    s2 = _normalize_num_token(s)
    if "," in s2 or "." in s2:
        dec = spec.get("decimal_separator", ".")
        try:
            if dec in (None, "auto"):
                fv = _parse_float_auto(s2)
            else:
                fv = _parse_float(raw, str(dec))
            return int(round(fv))
        except (ValueError, InvalidOperation):
            pass
    s = re.sub(r"[^\d-]", "", s)
    if not s:
        raise ValueError("leer")
    return int(s)


def convert_value(
    raw: str,
    db_field: str,
    spec: Mapping[str, Any] | None,
    module: str | None = None,
) -> Any:
    """
    Konvertiert eine Roh-Zelle in einen Python-Wert.
    spec kommt aus type_conversions[db_field].

    Optionen (JSON):
    - flexible: true — nach Primärformat Fallbacks (Datum/Zahl/Zeit/Duration).
    - decimal_separator: ".", ",", "auto" — bei auto Heuristik EU/US-Mischformen.
    - formats: [ "yyyy-mm-dd", "%d.%m.%y", ... ] — weitere strptime-/Alias-Ketten.
    - dayfirst: true|false — nur für dateutil-Fallback; Standard: true dann false.
    - source_unit: Registry-IDs (z. B. "kj", "kg") oder "custom"/"none" — letztere ohne
      vordefinierten Faktor; beliebige Skalierung dann nur über conversion_factor.
    - conversion_factor: Zusätzlicher Multiplikator nach dem Parsen (und nach source_unit);
      für nicht vordefinierte Umrechnungen source_unit weglassen oder "custom" setzen und
      hier den Faktor angeben.
    """
    if spec is None:
        return raw.strip() if raw else None
    if raw is None:
        return None
    s = raw.strip()
    if s == "":
        return None

    t = spec.get("type", "string")
    if t == "string":
        return s

    if t in ("float", "number"):
        v = _float_from_spec(raw, spec)
        if module:
            su = spec.get("source_unit")
            if su is not None and str(su).strip() != "":
                v = float(v) * factor_source_to_canonical(module, db_field, str(su))
        factor = spec.get("conversion_factor")
        if factor is not None:
            v = float(v) * float(factor)
        return v

    if t == "int":
        return _parse_int(raw, spec)

    if t == "date":
        return _parse_date_typed(s, spec)

    if t == "time":
        return _parse_time_typed(s, spec)

    if t == "datetime":
        return _parse_datetime_typed(s, spec)

    if t == "duration":
        target = spec.get("target_unit", "minutes")
        parts = [p.strip() for p in s.split(":")]
        flexible = bool(spec.get("flexible"))
        if len(parts) == 3:
            try:
                h, m, sec = int(parts[0]), int(parts[1]), int(parts[2])
                if target == "minutes":
                    return round(h * 60 + m + sec / 60.0, 4)
            except ValueError:
                if not flexible:
                    raise ValueError(f"Duration nicht parsbar: {s!r}") from None
            if flexible:
                try:
                    h, m, sec = int(parts[0]), int(parts[1]), float(parts[2])
                    if target == "minutes":
                        return round(h * 60 + m + sec / 60.0, 4)
                except ValueError:
                    pass
        if len(parts) == 2:
            try:
                h, m = int(parts[0]), int(parts[1])
                return h * 60 + m
            except ValueError:
                pass
        raise ValueError(f"Duration nicht parsbar: {s!r}")

    return s


def _lookup_db_field(csv_col: str, field_mappings: Mapping[str, str]) -> str | None:
    """
    CSV-Spaltennamen können Roh-Header sein; Vorlagen-Schlüssel oft normalisiert
    (wie column_signature). Exakter Treffer, dann Schlüssel nach Normalisierung,
    dann Abgleich aller Vorlagen-Keys über deren Normalform.
    """
    v = field_mappings.get(csv_col)
    if v:
        return v if v not in ("-", "_skip") else None
    norm = normalize_header_for_signature(csv_col)
    v = field_mappings.get(norm)
    if v:
        return v if v not in ("-", "_skip") else None
    for k, fv in field_mappings.items():
        if normalize_header_for_signature(str(k)) == norm:
            return fv if fv not in ("-", "_skip") else None
    return None


def _vitals_baseline_alias_db_field(csv_col: str) -> str | None:
    """
    Apple Health: deutsch „Vitalwerte.csv“ (Breitexport) vs. schmale Vorlage
    (Start / Resting Heart Rate …). Ohne Alias wählt die Analyse oft die
    englische Vorlage → jede Zeile „Datum fehlt“.
    Abgleich über normalisierten Header (normalize_header_for_signature).
    """
    n = normalize_header_for_signature(str(csv_col))
    if n in ("datum_uhrzeit", "start", "date_time", "datetime"):
        return "date"
    if "ruhepuls" in n or n.startswith("resting_heart_rate"):
        return "resting_hr"
    if "herzfrequenzvariabilit" in n or "heart_rate_variability" in n:
        return "hrv"
    if "vo2" in n and "max" in n:
        return "vo2_max"
    if "blutsauerstoff" in n or "oxygen_saturation" in n:
        return "spo2"
    if "atemfrequenz" in n or "respiratory_rate" in n:
        return "respiratory_rate"
    return None


def _blood_pressure_alias_db_field(csv_col: str) -> str | None:
    """
    Omron (schmal) vs. Apple-Gesundheit (Breitexport): unterschiedliche Spaltennamen;
    kombinierte Messzeit oft als „Start“ oder „Datum/Uhrzeit“.
    """
    n = normalize_header_for_signature(str(csv_col))
    low = str(csv_col).lower()
    if n in ("datum_uhrzeit", "datetime", "date_time", "messzeitpunkt"):
        return "start_time"
    if n in ("start", "beginn"):
        return "start_time"
    if n in ("datum", "date", "messdatum"):
        return "measured_date"
    if n in ("zeit", "time", "uhrzeit"):
        return "measured_time"
    if "systolisch" in n or ("blutdruck" in n and "systol" in low) or n.startswith("systolic"):
        return "systolic"
    if "diastolisch" in n or ("blutdruck" in n and "diastol" in low) or n.startswith("diastolic"):
        return "diastolic"
    if n.startswith("puls") or n.startswith("pulse") or "puls_" in n:
        return "pulse"
    return None


def _activity_alias_db_field(csv_col: str) -> str | None:
    """
    Apple-Workout schmal vs. Breitexport (viele Spalten): Trainingsart/Dauer/Strecke
    trotzdem zuverlässig erkennen.
    """
    n = normalize_header_for_signature(str(csv_col))
    low = str(csv_col).lower()
    if n in ("trainingsart", "workout_type", "activity_type", "workouttype"):
        return "activity_type"
    if ("trainings" in n and "art" in n) or ("workout" in low and "type" in low):
        return "activity_type"
    if n in ("datum_uhrzeit", "start", "beginn", "startzeit", "von"):
        return "start_time"
    if n in ("ende", "end", "endzeit", "bis"):
        return "end_time"
    if n in ("date", "datum"):
        return "date"
    if "dauer" in n or n == "duration" or n.startswith("duration_"):
        return "duration_min"
    if ("strecke" in n or "distance" in low) and ("km" in low or "(km" in low or " km" in low):
        return "distance_km"
    if "aktive_energie" in n or "active_energy" in n:
        return "kcal_active"
    if "ruheeintr" in n or "ruheenergie" in n or "resting_energy" in n:
        return "kcal_resting"
    if ("herzfrequenz" in n or "heart_rate" in n) and ("max" in low or "max" in n):
        return "hr_max"
    if (
        "durchschnittliche_herzfrequenz" in n
        or "heart_rate_average" in n
        or ("herzfrequenz" in n and ("durchschn" in n or "avg" in low or "average" in low))
        or ("heart_rate" in n and ("avg" in low or "average" in low))
    ):
        return "hr_avg"
    return None


def _effective_conversion_spec(
    db_field: str,
    spec: Mapping[str, Any] | None,
    module: str | None,
) -> Mapping[str, Any] | None:
    if spec is not None:
        return spec
    if module == "blood_pressure" and db_field == "start_time":
        return {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True}
    return None


def build_row_after_mapping(
    csv_row: Mapping[str, str],
    field_mappings: Mapping[str, str],
    type_conversions: Mapping[str, Any] | None,
    module: str | None = None,
) -> dict[str, Any]:
    """
    Wendet Zuordnung csv_spalte → db_feld und Typkonvertierung an.
    Unzugeordnete oder „—“ werden übersprungen.
    Die Reihenfolge der Spalten in der CSV spielt keine Rolle (Dict-Zugriff nach Name).
    Falls mehrere Spalten auf dasselbe db_field abbilden, gewinnt die zuletzt verarbeitete
    (iterreihenfolge = Kopfzeilen-Reihenfolge in der Datei) — in der Praxis selten.
    """
    out: dict[str, Any] = {}
    tc = type_conversions or {}
    for csv_col, raw in csv_row.items():
        db_field = _lookup_db_field(str(csv_col), field_mappings)
        if not db_field and module == "vitals_baseline":
            db_field = _vitals_baseline_alias_db_field(csv_col)
        elif not db_field and module == "blood_pressure":
            db_field = _blood_pressure_alias_db_field(csv_col)
        elif not db_field and module == "activity":
            db_field = _activity_alias_db_field(csv_col)
        if not db_field:
            continue
        raw_spec = tc.get(db_field) if isinstance(tc, dict) else None
        if not isinstance(raw_spec, dict):
            raw_spec = None
        spec = _effective_conversion_spec(db_field, raw_spec, module)
        try:
            out[db_field] = convert_value(
                raw, db_field, spec if isinstance(spec, dict) else None, module=module
            )
        except Exception:
            out[db_field] = None
    return out


def diagnose_row_mapping(
    csv_row: Mapping[str, str],
    field_mappings: Mapping[str, str],
    type_conversions: Mapping[str, Any] | None,
    module: str | None = None,
    *,
    mapped_typed: Mapping[str, Any] | None = None,
    max_columns: int = 512,
) -> dict[str, Any]:
    """
    Nur für Diagnose-Endpunkt: Quelle (Vorlage vs. Alias), Konvertierung pro Spalte,
    Ergebnis wie build_row_after_mapping (json-freundliche Vorschau).
    max_columns begrenzt nur die Länge der Liste „per_column“ in der Antwort — der echte
    Import verarbeitet alle Spalten (siehe iter_csv_dict_rows / build_row_after_mapping).
    """
    tc = type_conversions or {}
    per_column: list[dict[str, Any]] = []
    n = 0
    for csv_col, raw in csv_row.items():
        if n >= max_columns:
            break
        n += 1
        sc = str(csv_col)
        via_t = _lookup_db_field(sc, field_mappings)
        via_a = None
        if not via_t and module == "vitals_baseline":
            via_a = _vitals_baseline_alias_db_field(sc)
        elif not via_t and module == "blood_pressure":
            via_a = _blood_pressure_alias_db_field(sc)
        elif not via_t and module == "activity":
            via_a = _activity_alias_db_field(sc)
        target = via_t or via_a
        src = "template" if via_t else ("alias" if via_a else "none")
        raw_spec = tc.get(target) if isinstance(tc, dict) and target else None
        if not isinstance(raw_spec, dict):
            raw_spec = None
        spec = _effective_conversion_spec(target, raw_spec, module) if target else None
        conv_err: str | None = None
        conv_preview: Any = None
        if target:
            try:
                conv_val = convert_value(
                    (raw or "").strip(),
                    target,
                    spec if isinstance(spec, dict) else None,
                    module=module,
                )
                conv_preview = conv_val.isoformat() if hasattr(conv_val, "isoformat") else conv_val
            except Exception as e:
                conv_err = str(e)
        per_column.append(
            {
                "csv_column": sc,
                "raw_preview": ((raw or "")[:120]),
                "db_field": target,
                "source": src,
                "convert_error": conv_err,
                "converted_preview": conv_preview,
            }
        )

    src_map = (
        build_row_after_mapping(csv_row, field_mappings, type_conversions, module=module)
        if mapped_typed is None
        else mapped_typed
    )
    mapped_preview: dict[str, Any] = {}
    for k, v in src_map.items():
        mapped_preview[k] = v.isoformat() if hasattr(v, "isoformat") else v

    tmpl_keys = [
        str(k)
        for k, v in field_mappings.items()
        if v not in (None, "-", "_skip")
    ]

    return {
        "per_column": per_column,
        "columns_truncated": len(csv_row) > max_columns,
        "template_mapped_keys": tmpl_keys[:40],
        "template_mapped_keys_truncated": len(tmpl_keys) > 40,
        "mapped": mapped_preview,
    }
