from __future__ import annotations

import json
import math
from collections import defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Any, DefaultDict, Dict, Iterable, List, Optional, Tuple

from topaze.report_utils import ReportUtils
from topaze.toolbelt.i18n import tr

# ---------------------------------------------------------------------------
# Dataclasses de base
# ---------------------------------------------------------------------------


@dataclass
class Station:
    """Known or unknown point used as station or reference."""

    id: str
    x: Optional[float]
    y: Optional[float]
    z: Optional[float]


@dataclass
class Observation:
    """
    Raw observation, as read from JSON.
    - type = "traverse" | "reference" | "resection" | "intersection" | ...
    - face = "CG" ou "CD"
    """

    origine: str
    type: str
    occurrence: int
    hi: Optional[float]
    cible: str
    v0: Optional[float]
    ah: Optional[float]
    av: Optional[float]
    di: Optional[float]
    hp: Optional[float]
    face: str


@dataclass
class AggregatedObservation:
    """
    Observations CG/CD + répétitions moyennées par groupe
    (origine, occurrence, cible, type).
    """

    origine: str
    cible: str
    occurrence: int
    type: str

    # Moyennes après compensation
    ah_mean: Optional[float]  # horizontal angle (gon) normalisé (comme CG)
    av_mean: Optional[float]  # vertical angle (gon) normalisé (zenith angle supposé)
    di_mean: Optional[float]  # slope distance moyenne avant facteur linéaire
    di_corrected: Optional[float]  # slope distance corrigée (facteur linéaire)
    d_h: Optional[float]  # horizontal distance
    dZ_axis: Optional[float]  # delta Z entre axe instrument et prisme

    hi_mean: Optional[float]
    hp_mean: Optional[float]

    # V0 éventuellement forcé à ce niveau (moyenne si plusieurs valeurs)
    v0_override: Optional[float] = None

    # Direction gisement (gon) pour les visées (origine -> cible)
    direction_gon: Optional[float] = None


@dataclass
class SegmentResult:
    """Résultat d'un segment de cheminement (entre deux stations)."""

    origine: str
    cible: str
    occurrence: int

    direction_gon: Optional[float]
    d_h: Optional[float]
    di_corrected: Optional[float]

    dx: Optional[float]
    dy: Optional[float]

    z_from: Optional[float]
    z_to: Optional[float]
    dZ: Optional[float]


@dataclass
class BackForwardCheck:
    """Contrôle aller / retour pour une paire de segments A->B et B->A."""

    origine: str
    cible: str

    di_ab: Optional[float]
    di_ba: Optional[float]
    delta_di: Optional[float]
    ok_distance: Optional[bool]

    dir_ab: Optional[float]
    dir_ba: Optional[float]
    delta_dir_centered: Optional[float]
    ok_direction: Optional[bool]

    slope_ab: Optional[float]
    slope_ba: Optional[float]
    delta_slope: Optional[float]
    ok_vertical: Optional[bool]


@dataclass
class AngularClosureResult:
    """
    Résultat de la fermeture angulaire d'un cheminement polygonal fermé.

    On travaille sur la station de départ / d'arrivée (même sommet).
    """

    station: str

    v0_independent: Optional[float]  # V0 issu des références / v0 forcé
    v0_propagated: Optional[float]  # V0 propagé après avoir parcouru toute la boucle

    angular_closure: Optional[
        float
    ]  # fα (gon, centré [-200,+200]) = V0_connu - V0_calculé
    tolerance: Optional[float]
    ok: Optional[bool]


@dataclass
class PlanimetricClosureResult:
    """Résultat de la fermeture planimétrique et de son contrôle."""

    Fx: Optional[float]
    Fy: Optional[float]
    F: Optional[float]
    tolerance: Optional[float]
    ok: Optional[bool]


@dataclass
class AltimetricClosureResult:
    """Résultat de la fermeture altimétrique et de son contrôle."""

    station: str
    z_known: Optional[float]
    z_computed: Optional[float]
    closure: Optional[float]  # fZ = Z_computed - Z_known
    tolerance: Optional[float]
    ok: Optional[bool]


# ---------------------------------------------------------------------------
# Helpers math
# ---------------------------------------------------------------------------


def _deg_to_rad_gon(angle_gon: float) -> float:
    """Convertit un angle en gons vers radians."""
    return angle_gon * math.pi / 200.0


def _rad_to_gon(angle_rad: float) -> float:
    """Convertit un angle en radians vers gons, modulo 400g."""
    return (angle_rad * 200.0 / math.pi) % 400.0


def _circular_mean_gon(
    values: Iterable[float], weights: Optional[Iterable[float]] = None
) -> float:
    """
    Moyenne circulaire d'angles (en gons) avec poids optionnels.
    """
    vals = list(values)
    if not vals:
        raise ValueError(tr("Circular mean of empty angle set"))

    if weights is None:
        weights_list = [1.0] * len(vals)
    else:
        weights_list = list(weights)
        if len(weights_list) != len(vals):
            raise ValueError(tr("Angles and weights must have the same length"))

    sum_cos = 0.0
    sum_sin = 0.0
    for a, w in zip(vals, weights_list):
        phi = _deg_to_rad_gon(a)
        sum_cos += w * math.cos(phi)
        sum_sin += w * math.sin(phi)

    if sum_cos == 0.0 and sum_sin == 0.0:
        # Tous les angles se compensent exactement : on renvoie 0g arbitrairement
        return 0.0

    mean_rad = math.atan2(sum_sin, sum_cos)
    return _rad_to_gon(mean_rad)


def _normalize_hz_gon(ah: float, face: str) -> float:
    """
    Normalise un angle horizontal en gons en prenant en compte la face.

    Hypothèse:
      - pour une même direction, la lecture CD diffère de CG d'environ 200g.
      - on ramène toutes les lectures comme si elles étaient en face "CG".

    Donc:
      - face "CG": on ne touche à rien;
      - face "CD": on ajoute 200g puis modulo 400g.
    """
    if face.upper() == "CG":
        return ah % 400.0
    elif face.upper() == "CD":
        return (ah + 200.0) % 400.0
    else:
        raise ValueError(tr("Unknown face '{face}'").format(face=face))


def _normalize_v_gon(av: float, face: str) -> float:
    """
    Normalise un angle vertical en gons en prenant en compte la face.

    Hypothèse:
      - av est un angle zénithal en gons (0g = zénith, 100g = horizontale, 200g = nadir).
      - les lectures CG et CD vérifient ~: av_CG + av_CD ≈ 400g.
      - on ramène toutes les lectures comme si elles étaient en face "CG".

    Donc:
      - face "CG": av_norm = av
      - face "CD": av_norm = 400g - av
    """
    if face.upper() == "CG":
        return av % 400.0
    elif face.upper() == "CD":
        return (400.0 - av) % 400.0
    else:
        raise ValueError(tr("Unknown face '{face}'").format(face=face))


def _azimuth_from_coords(x1: float, y1: float, x2: float, y2: float) -> float:
    """
    Azimut en gons de (x1, y1) vers (x2, y2).
    0g = nord, 100g = est, 200g = sud, 300g = ouest.
    """
    dx = x2 - x1
    dy = y2 - y1
    angle_rad = math.atan2(dx, dy)  # angle depuis l'axe Y (Nord)
    return _rad_to_gon(angle_rad)


# ---------------------------------------------------------------------------
# Parsing JSON
# ---------------------------------------------------------------------------


def _load_closed_json(json_path: Path) -> Dict[str, Any]:
    with json_path.open("r", encoding="utf-8") as f:
        data = json.load(f)
    return data


def _parse_stations(data: Dict[str, Any]) -> Dict[str, Station]:
    stations_data = data.get("stations", [])
    stations: Dict[str, Station] = {}

    for row in stations_data:
        sid = row["matricule"]
        x = row.get("x")
        y = row.get("y")
        z = row.get("z")
        stations[sid] = Station(id=sid, x=x, y=y, z=z)

    return stations


def _parse_observations(data: Dict[str, Any]) -> List[Observation]:
    obs_list: List[Observation] = []

    for row in data.get("obs", []):
        obs = Observation(
            origine=row["origine"],
            type=row.get("type", "traverse"),
            occurrence=int(row.get("occurrence", 1)),
            hi=row.get("hi"),
            cible=row["cible"],
            v0=row.get("v0"),
            ah=row.get("ah"),
            av=row.get("av"),
            di=row.get("di"),
            hp=row.get("hp"),
            face=row.get("face", "CG"),
        )
        obs_list.append(obs)

    return obs_list


def _ensure_all_stations_defined(
    stations: Dict[str, Station], data: Dict[str, Any]
) -> Dict[str, Station]:
    """
    S'assure que toutes les stations mentionnées dans stations_sequence,
    calcul et obs existent au moins dans le dict 'stations'.

    Si une station n'est pas définie dans la section 'stations' du JSON,
    elle est créée avec x/y/z = None.
    """
    needed_ids: set[str] = set()

    # 1) stations_sequence
    for sid in data.get("stations_sequence", []):
        needed_ids.add(sid)

    # 2) calcul (stations pour lesquelles on veut des coords en sortie)
    for sid in data.get("calcul", []):
        needed_ids.add(sid)

    # 3) obs (origines + cibles)
    for row in data.get("obs", []):
        needed_ids.add(row["origine"])
        needed_ids.add(row["cible"])

    # Création des stations manquantes
    for sid in needed_ids:
        if sid not in stations:
            stations[sid] = Station(id=sid, x=None, y=None, z=None)

    return stations


# ---------------------------------------------------------------------------
# Agrégation des observations (compensation CG/CD + moyennes)
# ---------------------------------------------------------------------------


def _aggregate_observations(
    observations: List[Observation],
    linear_scale_factor: float,
) -> Tuple[
    Dict[Tuple[str, int, str, str], AggregatedObservation],
    Dict[Tuple[str, int], List[AggregatedObservation]],
]:
    """
    Regroupe les observations par (origine, occurrence, cible, type)
    et compense CG/CD en calculant des moyennes.

    Retourne:
      - dict (origine, occurrence, cible, type) -> AggregatedObservation
      - dict (origine, occurrence) -> [AggregatedObservation, ...]
    """
    grouped: DefaultDict[Tuple[str, int, str, str], List[Observation]] = defaultdict(
        list
    )
    for o in observations:
        key = (o.origine, o.occurrence, o.cible, o.type)
        grouped[key].append(o)

    aggregated: Dict[Tuple[str, int, str, str], AggregatedObservation] = {}
    by_station_occ: DefaultDict[
        Tuple[str, int], List[AggregatedObservation]
    ] = defaultdict(list)

    for key, obs_list in grouped.items():
        origine, occurrence, cible, type_ = key

        # Moyennes hi / hp / di / ah / av / v0
        hi_vals = [o.hi for o in obs_list if o.hi is not None]
        hp_vals = [o.hp for o in obs_list if o.hp is not None]
        di_vals = [o.di for o in obs_list if o.di is not None]
        ah_norm_vals = [
            _normalize_hz_gon(o.ah, o.face) for o in obs_list if o.ah is not None
        ]
        av_norm_vals = [
            _normalize_v_gon(o.av, o.face) for o in obs_list if o.av is not None
        ]
        v0_overrides = [o.v0 for o in obs_list if o.v0 is not None]

        hi_mean = sum(hi_vals) / len(hi_vals) if hi_vals else None
        hp_mean = sum(hp_vals) / len(hp_vals) if hp_vals else None
        di_mean = sum(di_vals) / len(di_vals) if di_vals else None

        ah_mean = _circular_mean_gon(ah_norm_vals) if ah_norm_vals else None
        av_mean = _circular_mean_gon(av_norm_vals) if av_norm_vals else None

        v0_override = _circular_mean_gon(v0_overrides) if v0_overrides else None

        di_corrected = di_mean * linear_scale_factor if di_mean is not None else None

        # Conversion en distance horizontale + delta Z (axe -> prisme)
        d_h = None
        dZ_axis = None
        if di_corrected is not None:
            if av_mean is not None:
                # Hypothèse: av = angle zénithal, donc:
                #   d_h = s * sin(Z)
                #   dZ = s * cos(Z)
                av_rad = _deg_to_rad_gon(av_mean)
                d_h = di_corrected * math.sin(av_rad)
                dZ_axis = di_corrected * math.cos(av_rad)
            else:
                # Pas d'angle vertical : on considère la distance comme déjà horizontale.
                d_h = di_corrected
                dZ_axis = None

        agg = AggregatedObservation(
            origine=origine,
            cible=cible,
            occurrence=occurrence,
            type=type_,
            ah_mean=ah_mean,
            av_mean=av_mean,
            di_mean=di_mean,
            di_corrected=di_corrected,
            d_h=d_h,
            dZ_axis=dZ_axis,
            hi_mean=hi_mean,
            hp_mean=hp_mean,
            v0_override=v0_override,
        )
        aggregated[key] = agg
        by_station_occ[(origine, occurrence)].append(agg)

    return aggregated, by_station_occ


# ---------------------------------------------------------------------------
# Calcul des V0 par station / occurrence
# (identique à FramedTraverse : v0 forcé, références, visées traverse, transmission)
# ---------------------------------------------------------------------------


def _compute_v0_by_station_occ(
    stations: Dict[str, Station],
    aggregated_by_station_occ: Dict[Tuple[str, int], List[AggregatedObservation]],
    stations_sequence: List[str],
) -> Dict[Tuple[str, int], float]:
    """
    Calcule V0 pour chaque station / occurrence.

    Priorité:
      1) v0_override dans une des observations du groupe (quel que soit type)
      2) calcul à partir des observations de type "reference"
      3) si pas de référence : recalcul local à partir des visées aller / retour
         avec la station précédente (dont le V0 est déjà connu)
      4) en tout dernier recours : transmission depuis la station précédente
         (dans stations_sequence)
    """
    v0_by_station_occ: Dict[Tuple[str, int], float] = {}

    index_by_station = {sid: idx for idx, sid in enumerate(stations_sequence)}

    for sid in stations_sequence:
        # Occurrences connues pour cette station
        occs = sorted(
            occ for (st, occ) in aggregated_by_station_occ.keys() if st == sid
        )
        for occ in occs:
            key_so = (sid, occ)
            group = aggregated_by_station_occ.get(key_so, [])

            # 1) V0 forcé dans les observations (override)
            overrides = [
                agg.v0_override for agg in group if agg.v0_override is not None
            ]
            if overrides:
                v0 = _circular_mean_gon(overrides)
                v0_by_station_occ[key_so] = v0
                continue

            # 2) Calcul V0 à partir des visées type "reference"
            ref_aggs = [agg for agg in group if agg.type == "reference"]

            v0_candidates: List[Tuple[float, float]] = []  # (V0_i, poids)

            for agg in ref_aggs:
                st = stations.get(sid)
                ref = stations.get(agg.cible)
                if st is None or ref is None:
                    continue
                if st.x is None or st.y is None or ref.x is None or ref.y is None:
                    # Pas de coordonnées suffisantes
                    continue
                if agg.ah_mean is None:
                    continue

                g_th = _azimuth_from_coords(st.x, st.y, ref.x, ref.y)
                hz = agg.ah_mean
                v0_i = (g_th - hz) % 400.0
                # poids = distance, sinon 1
                w = agg.di_corrected if agg.di_corrected is not None else 1.0
                v0_candidates.append((v0_i, w))

            if v0_candidates:
                angles = [a for (a, _) in v0_candidates]
                weights = [w for (_, w) in v0_candidates]
                v0 = _circular_mean_gon(angles, weights)
                v0_by_station_occ[key_so] = v0
                continue

            # 3) Pas de référence : tentative de recalcul local avec visées aller / retour
            idx = index_by_station.get(sid, None)
            if idx is None:
                raise ValueError(
                    tr("Station '{station}' is not in stations_sequence").format(
                        station=sid
                    )
                )

            if idx > 0:
                prev_station = stations_sequence[idx - 1]

                # Visées "retour" depuis la station courante vers la station précédente
                back_traverses = [
                    agg
                    for agg in group
                    if agg.type == "traverse"
                    and agg.cible == prev_station
                    and agg.ah_mean is not None
                ]

                local_candidates: List[Tuple[float, float]] = []

                if back_traverses:
                    # Occurrences de la station précédente
                    prev_occs = sorted(
                        o
                        for (st, o) in aggregated_by_station_occ.keys()
                        if st == prev_station
                    )
                    for occ_prev in prev_occs:
                        key_prev = (prev_station, occ_prev)
                        if key_prev not in v0_by_station_occ:
                            # Pas encore de V0 pour cette occurrence de la station précédente
                            continue
                        v0_prev = v0_by_station_occ[key_prev]
                        prev_group = aggregated_by_station_occ.get(key_prev, [])

                        # Visées "aller" depuis la station précédente vers la station courante
                        forward_traverses = [
                            agg
                            for agg in prev_group
                            if agg.type == "traverse"
                            and agg.cible == sid
                            and agg.ah_mean is not None
                        ]
                        if not forward_traverses:
                            continue

                        for agg_ab in forward_traverses:
                            hz_ab = agg_ab.ah_mean
                            for agg_ba in back_traverses:
                                hz_ba = agg_ba.ah_mean
                                # Az_AB_obs = V0_prev + hz_ab
                                # Az_BA_obs = V0_sid + hz_ba
                                # On veut Az_AB_obs ≈ Az_BA_obs + 200
                                # => V0_sid ≈ V0_prev + hz_ab - 200 - hz_ba
                                v0_candidate = (v0_prev + hz_ab - 200.0 - hz_ba) % 400.0
                                # poids: on peut utiliser une moyenne des distances si dispo
                                w_ab = (
                                    agg_ab.di_corrected
                                    if agg_ab.di_corrected is not None
                                    else 1.0
                                )
                                w_ba = (
                                    agg_ba.di_corrected
                                    if agg_ba.di_corrected is not None
                                    else 1.0
                                )
                                weight = (w_ab + w_ba) / 2.0
                                local_candidates.append((v0_candidate, weight))

                if local_candidates:
                    angles = [a for (a, _) in local_candidates]
                    weights = [w for (_, w) in local_candidates]
                    v0 = _circular_mean_gon(angles, weights)
                    v0_by_station_occ[key_so] = v0
                    continue

            # 4) En dernier recours : transmission depuis la station précédente
            if idx == 0:
                # Première station, impossible de transmettre
                raise ValueError(
                    tr(
                        "Cannot determine orientation V0 for first station '{station}'"
                    ).format(station=sid)
                )

            prev_station = stations_sequence[idx - 1]
            # On préfère même occurrence; sinon, première occurrence dispo pour prev_station
            prev_key_same_occ = (prev_station, occ)
            if prev_key_same_occ in v0_by_station_occ:
                v0 = v0_by_station_occ[prev_key_same_occ]
            else:
                prev_candidates = [
                    k for k in v0_by_station_occ.keys() if k[0] == prev_station
                ]
                if not prev_candidates:
                    raise ValueError(
                        tr(
                            "Cannot transmit V0 to station '{station}' "
                            "because previous station '{prev}' has no orientation"
                        ).format(station=sid, prev=prev_station)
                    )
                prev_key = sorted(prev_candidates, key=lambda k: k[1])[0]
                v0 = v0_by_station_occ[prev_key]

            v0_by_station_occ[key_so] = v0

    return v0_by_station_occ


# ---------------------------------------------------------------------------
# Calcul des segments (cheminement XY / Z)
# ---------------------------------------------------------------------------


def _get_v0_for_segment(
    v0_by_station_occ: Dict[Tuple[str, int], float],
    station_id: str,
    occurrence: int,
) -> Optional[float]:
    """Récupère le V0 pour (station, occurrence) ou le premier dispo pour cette station."""
    key = (station_id, occurrence)
    if key in v0_by_station_occ:
        return v0_by_station_occ[key]

    # Sinon première occurrence disponible pour la station
    candidates = [
        v0 for (st, _occ), v0 in v0_by_station_occ.items() if st == station_id
    ]
    if not candidates:
        return None
    return candidates[0]


def _compute_segments(
    stations: Dict[str, Station],
    stations_sequence: List[str],
    aggregated: Dict[Tuple[str, int, str, str], AggregatedObservation],
    v0_by_station_occ: Dict[Tuple[str, int], float],
    compute_z: bool,
) -> Tuple[Dict[str, Station], List[SegmentResult]]:
    """
    Propage le cheminement le long de stations_sequence en utilisant
    les visées (type == "traverse") et les V0.

    Pour un cheminement fermé, stations_sequence doit contenir la station de
    départ en première et dernière position, par exemple:
        ["S1", "S2", "S3", "S4", "S1"]
    """
    # Copie des stations pour mettre à jour X, Y, Z calculés
    stations_out: Dict[str, Station] = {
        sid: Station(id=s.id, x=s.x, y=s.y, z=s.z) for sid, s in stations.items()
    }

    if not stations_sequence:
        return stations_out, []

    # Station de départ
    first_sid = stations_sequence[0]
    if first_sid not in stations_out:
        raise ValueError(
            tr("First station '{station}' not found in 'stations'").format(
                station=first_sid
            )
        )

    first_st = stations_out[first_sid]
    if first_st.x is None or first_st.y is None:
        raise ValueError(
            tr("First station '{station}' must have known X and Y coordinates").format(
                station=first_sid
            )
        )

    # On ne bloque plus le cheminement XY si la cote de départ est inconnue.
    can_compute_z = compute_z and first_st.z is not None

    segments: List[SegmentResult] = []

    # Index pratique pour trouver les agrégats de type traverse
    traverse_aggs: List[AggregatedObservation] = [
        agg for agg in aggregated.values() if agg.type == "traverse"
    ]

    for i in range(len(stations_sequence) - 1):
        sid_from = stations_sequence[i]
        sid_to = stations_sequence[i + 1]

        st_from = stations_out[sid_from]
        st_to = stations_out[sid_to]

        # On cherche les agrégats pour cette visée (origine = sid_from, cible = sid_to)
        cand = [
            agg
            for agg in traverse_aggs
            if agg.origine == sid_from and agg.cible == sid_to
        ]

        if not cand:
            raise ValueError(
                tr("No 'traverse' observations found from '{frm}' to '{to}'").format(
                    frm=sid_from, to=sid_to
                )
            )

        # On prend la première occurrence (la plus petite) pour cette paire
        cand.sort(key=lambda a: a.occurrence)
        agg = cand[0]

        # V0 pour cette station / occurrence
        v0 = _get_v0_for_segment(v0_by_station_occ, sid_from, agg.occurrence)
        if v0 is None:
            raise ValueError(
                tr(
                    "No orientation V0 found for station '{station}' (occurrence {occ})"
                ).format(station=sid_from, occ=agg.occurrence)
            )

        # Direction du segment
        if agg.ah_mean is not None:
            direction_gon = (v0 + agg.ah_mean) % 400.0
        else:
            direction_gon = None

        agg.direction_gon = direction_gon

        dx = dy = None
        d_h = agg.d_h if agg.d_h is not None else agg.di_corrected
        if direction_gon is not None and d_h is not None:
            dir_rad = _deg_to_rad_gon(direction_gon)
            dx = d_h * math.sin(dir_rad)
            dy = d_h * math.cos(dir_rad)

        # Mise à jour des coordonnées XY de la station cible
        if dx is not None and dy is not None:
            if st_from.x is None or st_from.y is None:
                raise ValueError(
                    tr(
                        "Station '{station}' has undefined coordinates, cannot propagate traverse"
                    ).format(station=sid_from)
                )
            x_to = st_from.x + dx
            y_to = st_from.y + dy
            stations_out[sid_to].x = x_to
            stations_out[sid_to].y = y_to

        # Calcul Z
        z_from = stations_out[sid_from].z
        z_to = stations_out[sid_to].z
        dZ = None
        if can_compute_z and z_from is not None:
            if (
                agg.di_corrected is not None
                and agg.av_mean is not None
                and agg.hi_mean is not None
                and agg.hp_mean is not None
            ):
                av_rad = _deg_to_rad_gon(agg.av_mean)
                # Même hypothèse que plus haut (angle zénithal)
                dZ_axis = agg.di_corrected * math.cos(av_rad)
                z_prism = z_from + agg.hi_mean + dZ_axis
                z_to_calc = z_prism - agg.hp_mean
                stations_out[sid_to].z = z_to_calc
                z_to = z_to_calc
                dZ = z_to - z_from
            # sinon, on laisse Z_to tel qu'il est (None ou déjà connu)

        seg = SegmentResult(
            origine=sid_from,
            cible=sid_to,
            occurrence=agg.occurrence,
            direction_gon=direction_gon,
            d_h=agg.d_h,
            di_corrected=agg.di_corrected,
            dx=dx,
            dy=dy,
            z_from=z_from,
            z_to=z_to,
            dZ=dZ,
        )
        segments.append(seg)

    return stations_out, segments


# ---------------------------------------------------------------------------
# Contrôles aller / retour
# ---------------------------------------------------------------------------


def _compute_back_forward_checks(
    aggregated: Dict[Tuple[str, int, str, str], AggregatedObservation],
    v0_by_station_occ: Dict[Tuple[str, int], float],
    distance_tol: float,
    direction_tol: float,
    vertical_slope_tol: float,
) -> List[BackForwardCheck]:
    """
    Contrôles aller / retour sur les segments de type "traverse".
    """
    traverse_keys = [key for key, agg in aggregated.items() if agg.type == "traverse"]
    used_pairs: set[Tuple[str, str]] = set()
    checks: List[BackForwardCheck] = []

    for key in traverse_keys:
        origine, occ, cible, _type = key
        pair = (origine, cible)
        if pair in used_pairs:
            continue

        reverse_key_candidates = [
            k for k in traverse_keys if k[0] == cible and k[2] == origine
        ]
        if not reverse_key_candidates:
            # Pas de visée retour pour cette paire
            used_pairs.add(pair)
            continue

        reverse_key_candidates.sort(key=lambda k: k[1])  # occurrence
        key_ab = key
        key_ba = reverse_key_candidates[0]

        agg_ab = aggregated[key_ab]
        agg_ba = aggregated[key_ba]

        used_pairs.add(pair)
        used_pairs.add((cible, origine))

        # Distances (slope)
        di_ab = agg_ab.di_corrected
        di_ba = agg_ba.di_corrected
        delta_di = None
        ok_distance: Optional[bool] = None
        if di_ab is not None and di_ba is not None:
            delta_di = abs(di_ab - di_ba)
            ok_distance = delta_di <= distance_tol

        # Directions
        v0_ab = _get_v0_for_segment(v0_by_station_occ, origine, agg_ab.occurrence)
        v0_ba = _get_v0_for_segment(v0_by_station_occ, cible, agg_ba.occurrence)

        dir_ab = dir_ba = None
        delta_dir_centered = None
        ok_direction: Optional[bool] = None

        if v0_ab is not None and agg_ab.ah_mean is not None:
            dir_ab = (v0_ab + agg_ab.ah_mean) % 400.0
        if v0_ba is not None and agg_ba.ah_mean is not None:
            dir_ba = (v0_ba + agg_ba.ah_mean) % 400.0

        if dir_ab is not None and dir_ba is not None:
            # On s'attend à ce que dir_ab ~ dir_ba + 200g
            delta_dir = (dir_ab - dir_ba - 200.0) % 400.0
            # Recentre entre -200g et +200g
            if delta_dir > 200.0:
                delta_dir_centered = delta_dir - 400.0
            else:
                delta_dir_centered = delta_dir
            ok_direction = abs(delta_dir_centered) <= direction_tol

        # Vertical (pente)
        slope_ab = slope_ba = delta_slope = None
        ok_vertical: Optional[bool] = None

        if di_ab and agg_ab.dZ_axis is not None:
            slope_ab = agg_ab.dZ_axis / di_ab
        if di_ba and agg_ba.dZ_axis is not None:
            slope_ba = agg_ba.dZ_axis / di_ba

        if slope_ab is not None and slope_ba is not None:
            # Pour une même ligne, les pentes (axe->prisme) doivent être opposées
            # approx: slope_ab ≈ -slope_ba
            delta_slope = slope_ab + slope_ba
            ok_vertical = abs(delta_slope) <= vertical_slope_tol

        checks.append(
            BackForwardCheck(
                origine=origine,
                cible=cible,
                di_ab=di_ab,
                di_ba=di_ba,
                delta_di=delta_di,
                ok_distance=ok_distance,
                dir_ab=dir_ab,
                dir_ba=dir_ba,
                delta_dir_centered=delta_dir_centered,
                ok_direction=ok_direction,
                slope_ab=slope_ab,
                slope_ba=slope_ba,
                delta_slope=delta_slope,
                ok_vertical=ok_vertical,
            )
        )

    return checks


# ---------------------------------------------------------------------------
# Fermeture angulaire (cheminement fermé)
# ---------------------------------------------------------------------------


def _propagate_v0_along_sequence(
    stations_sequence: List[str],
    aggregated_by_station_occ: Dict[Tuple[str, int], List[AggregatedObservation]],
    start_station: str,
    v0_start: float,
    occ_start: int,
) -> Dict[Tuple[str, int], float]:
    """
    Propage un V0 le long du cheminement en utilisant uniquement les visées
    de type 'traverse' aller/retour entre stations successives.

    Pour un cheminement fermé, stations_sequence contient la station de départ
    en première et dernière position (boucle: S0,...,Sn,S0).

    Retourne un dict (station, occurrence) -> V0_propagé.
    """
    index_by_station = {sid: idx for idx, sid in enumerate(stations_sequence)}
    prop_v0: Dict[Tuple[str, int], float] = {}

    if start_station not in index_by_station:
        return prop_v0

    prop_v0[(start_station, occ_start)] = v0_start

    # On part de start_station et on propage dans l'ordre de la séquence
    start_idx = index_by_station[start_station]
    for idx in range(start_idx + 1, len(stations_sequence)):
        sid_prev = stations_sequence[idx - 1]
        sid_curr = stations_sequence[idx]

        # On récupère l'occurrence pour laquelle on a déjà propagé V0 sur la station précédente
        prev_occs_prop = [occ for (st, occ) in prop_v0.keys() if st == sid_prev]
        if not prev_occs_prop:
            break
        occ_prev = sorted(prev_occs_prop)[0]
        v0_prev = prop_v0[(sid_prev, occ_prev)]

        # On choisit la plus petite occurrence dispo pour la station courante
        occs_curr = sorted(
            occ for (st, occ) in aggregated_by_station_occ.keys() if st == sid_curr
        )
        if not occs_curr:
            break
        occ_curr = occs_curr[0]

        prev_group = aggregated_by_station_occ.get((sid_prev, occ_prev), [])
        curr_group = aggregated_by_station_occ.get((sid_curr, occ_curr), [])

        # Visées "aller" et "retour" de type traverse
        forward_traverses = [
            agg
            for agg in prev_group
            if agg.type == "traverse"
            and agg.cible == sid_curr
            and agg.ah_mean is not None
        ]
        back_traverses = [
            agg
            for agg in curr_group
            if agg.type == "traverse"
            and agg.cible == sid_prev
            and agg.ah_mean is not None
        ]

        local_candidates: List[Tuple[float, float]] = []

        if forward_traverses and back_traverses:
            for agg_ab in forward_traverses:
                hz_ab = agg_ab.ah_mean
                for agg_ba in back_traverses:
                    hz_ba = agg_ba.ah_mean
                    # Az_AB_obs = V0_prev + hz_ab
                    # Az_BA_obs = V0_curr + hz_ba
                    # On veut Az_AB_obs ≈ Az_BA_obs + 200
                    # => V0_curr ≈ V0_prev + hz_ab - 200 - hz_ba
                    v0_candidate = (v0_prev + hz_ab - 200.0 - hz_ba) % 400.0
                    w_ab = (
                        agg_ab.di_corrected if agg_ab.di_corrected is not None else 1.0
                    )
                    w_ba = (
                        agg_ba.di_corrected if agg_ba.di_corrected is not None else 1.0
                    )
                    weight = (w_ab + w_ba) / 2.0
                    local_candidates.append((v0_candidate, weight))

        if not local_candidates:
            # Impossible de propager plus loin proprement
            break

        angles = [a for (a, _) in local_candidates]
        weights = [w for (_, w) in local_candidates]
        v0_curr = _circular_mean_gon(angles, weights)
        prop_v0[(sid_curr, occ_curr)] = v0_curr

    return prop_v0


def _compute_angular_closure(
    stations_sequence: List[str],
    aggregated_by_station_occ: Dict[Tuple[str, int], List[AggregatedObservation]],
    v0_by_station_occ: Dict[Tuple[str, int], float],
    angular_tol: Optional[float],
) -> AngularClosureResult:
    """
    Calcule la fermeture angulaire d'un cheminement fermé.

    - station = première station de stations_sequence (également station d'arrivée).
    - V0_independent = V0 calculé/forcé pour cette station (occurrence minimale),
    - V0_propagated = V0 obtenu en propageant V0_independent le long de la boucle,
      jusqu'à revenir sur cette station.

    f_alpha = V0_connu - V0_calculé (centré dans [-200g,+200g]).
    """
    if not stations_sequence:
        return AngularClosureResult(
            station="",
            v0_independent=None,
            v0_propagated=None,
            angular_closure=None,
            tolerance=angular_tol,
            ok=None,
        )

    start_station = stations_sequence[0]
    end_station = stations_sequence[-1]
    # On s'attend à ce que end_station == start_station pour une boucle fermée

    # Occurrences avec V0 pour la station de départ
    occs_start = sorted(
        occ for (st, occ) in v0_by_station_occ.keys() if st == start_station
    )
    if not occs_start:
        return AngularClosureResult(
            station=start_station,
            v0_independent=None,
            v0_propagated=None,
            angular_closure=None,
            tolerance=angular_tol,
            ok=None,
        )

    occ_start = occs_start[0]
    v0_indep = v0_by_station_occ[(start_station, occ_start)]

    # Propagation du V0 le long de la séquence (incluant le retour à la station de départ)
    prop_v0 = _propagate_v0_along_sequence(
        stations_sequence=stations_sequence,
        aggregated_by_station_occ=aggregated_by_station_occ,
        start_station=start_station,
        v0_start=v0_indep,
        occ_start=occ_start,
    )

    # V0 propagé à la station d'arrivée (qui est la même que la station de départ)
    end_prop_candidates = [
        (occ, v) for (st, occ), v in prop_v0.items() if st == end_station
    ]
    if not end_prop_candidates:
        return AngularClosureResult(
            station=start_station,
            v0_independent=v0_indep,
            v0_propagated=None,
            angular_closure=None,
            tolerance=angular_tol,
            ok=None,
        )

    end_prop_candidates.sort(key=lambda x: x[0])
    occ_end_prop, v0_prop = end_prop_candidates[0]

    # Fermeture angulaire: V0_connu - V0_calculé
    delta_raw = (v0_indep - v0_prop) % 400.0
    if delta_raw > 200.0:
        f_alpha = delta_raw - 400.0
    else:
        f_alpha = delta_raw

    ok = None
    if angular_tol is not None:
        ok = abs(f_alpha) <= angular_tol

    return AngularClosureResult(
        station=start_station,
        v0_independent=v0_indep,
        v0_propagated=v0_prop,
        angular_closure=f_alpha,
        tolerance=angular_tol,
        ok=ok,
    )


def _compute_altimetric_closure(
    stations_sequence: List[str],
    stations_original: Dict[str, Station],
    stations_out_raw: Dict[str, Station],
    alt_tol: Optional[float],
) -> AltimetricClosureResult:
    """
    Calcule la fermeture altimétrique pour un cheminement fermé.

    Hypothèse :
      - la boucle part et revient sur la même station (stations_sequence[0]).
      - Z de la station de départ est connue dans 'stations_original'.
      - 'stations_out_raw' contient le Z recalculé après avoir bouclé.

    fZ = Z_computed - Z_known
    (on compare |fZ| à la tolérance altimétrique).
    """
    if not stations_sequence:
        return AltimetricClosureResult(
            station="",
            z_known=None,
            z_computed=None,
            closure=None,
            tolerance=alt_tol,
            ok=None,
        )

    station = stations_sequence[0]

    st_orig = stations_original.get(station)
    st_raw = stations_out_raw.get(station)

    z_known = st_orig.z if st_orig is not None else None
    z_computed = st_raw.z if st_raw is not None else None

    if z_known is None or z_computed is None:
        return AltimetricClosureResult(
            station=station,
            z_known=z_known,
            z_computed=z_computed,
            closure=None,
            tolerance=alt_tol,
            ok=None,
        )

    fz = z_computed - z_known
    ok = None
    if alt_tol is not None:
        ok = abs(fz) <= alt_tol

    return AltimetricClosureResult(
        station=station,
        z_known=z_known,
        z_computed=z_computed,
        closure=fz,
        tolerance=alt_tol,
        ok=ok,
    )


# ---------------------------------------------------------------------------
# Compensation angulaire + fermeture planimétrique (Bowditch)
# ---------------------------------------------------------------------------


def _apply_angular_and_planimetric_adjustments(
    stations_original: Dict[str, Station],
    stations_out_raw: Dict[str, Station],
    stations_sequence: List[str],
    segments: List[SegmentResult],
    angular_closure: AngularClosureResult,
    planimetric_tol: Optional[float],
) -> Tuple[Dict[str, Station], List[SegmentResult], PlanimetricClosureResult]:
    """
    1) Répartit la fermeture angulaire f_alpha sur N côtés
       (correction égale sur chaque gisement).
    2) Recalcule la polygonale en XY à partir des gisements corrigés.
    3) Calcule la fermeture planimétrique (Fx, Fy, F) entre la coordonnée
       finale de la boucle et la coordonnée connue de la station de départ.
    4) Répartit la fermeture planimétrique sur tous les sommets inconnus
       (stations intermédiaires) proportionnellement aux distances cumulées
       (méthode type Bowditch).
    """
    # Copie de travail pour les stations (pour conserver les Z calculés)
    stations_out: Dict[str, Station] = {
        sid: Station(id=s.id, x=s.x, y=s.y, z=s.z)
        for sid, s in stations_out_raw.items()
    }

    n = len(stations_sequence)
    if n == 0 or not segments:
        return (
            stations_out,
            segments,
            PlanimetricClosureResult(
                Fx=None, Fy=None, F=None, tolerance=planimetric_tol, ok=None
            ),
        )

    start_id = stations_sequence[0]
    st_start_orig = stations_original.get(start_id)

    # Si on ne connaît pas les coords de départ, on abandonne la correction
    if st_start_orig is None or st_start_orig.x is None or st_start_orig.y is None:
        return (
            stations_out,
            segments,
            PlanimetricClosureResult(
                Fx=None, Fy=None, F=None, tolerance=planimetric_tol, ok=None
            ),
        )

    # 1) Répartition de la fermeture angulaire sur les N côtés
    N = len(segments)
    f_alpha = angular_closure.angular_closure or 0.0  # en gon
    # f_alpha = V0_connu - V0_calculé
    # Correction par côté: on ajoute f_alpha/N pour ramener V0_calculé vers V0_connu
    delta_g = f_alpha / N if N > 0 else 0.0

    # Coordonnées provisoires (après correction angulaire, avant Bowditch)
    X = [0.0] * n
    Y = [0.0] * n
    X[0] = st_start_orig.x
    Y[0] = st_start_orig.y

    for i, seg in enumerate(segments):
        g_orig = seg.direction_gon
        if g_orig is not None:
            g_corr = (g_orig + delta_g) % 400.0
        else:
            g_corr = None
        seg.direction_gon = g_corr  # gisement corrigé = gisement définitif

        d_h = seg.d_h if seg.d_h is not None else seg.di_corrected
        dx = dy = None
        if d_h is not None and g_corr is not None:
            g_rad = _deg_to_rad_gon(g_corr)
            dx = d_h * math.sin(g_rad)
            dy = d_h * math.cos(g_rad)
            X[i + 1] = X[i] + dx
            Y[i + 1] = Y[i] + dy
        else:
            # si info insuffisante, on propage sans mouvement
            X[i + 1] = X[i]
            Y[i + 1] = Y[i]

        seg.dx = dx
        seg.dy = dy

    # 2) Fermeture planimétrique avant compensation (boucle sur station de départ)
    Fx = X[-1] - X[0]
    Fy = Y[-1] - Y[0]
    F = math.hypot(Fx, Fy)

    ok_plan = None
    if planimetric_tol is not None:
        ok_plan = F <= planimetric_tol

    plan_closure = PlanimetricClosureResult(
        Fx=Fx, Fy=Fy, F=F, tolerance=planimetric_tol, ok=ok_plan
    )

    # 3) Répartition de la fermeture planimétrique (Bowditch sur sommets)
    # Distances cumulées L_j
    L = [0.0] * n
    for j in range(1, n):
        seg = segments[j - 1]
        d_h = seg.d_h if seg.d_h is not None else seg.di_corrected or 0.0
        L[j] = L[j - 1] + d_h
    L_tot = L[-1]

    if L_tot > 0 and n >= 2:
        for j in range(1, n):  # on corrige tous les sommets sauf le premier
            factor = L[j] / L_tot
            dXj = -Fx * factor
            dYj = -Fy * factor
            X[j] += dXj
            Y[j] += dYj

    # 4) On impose la coordonnée de départ connue (0 et dernier doivent coïncider)
    X[0] = st_start_orig.x
    Y[0] = st_start_orig.y
    X[-1] = st_start_orig.x
    Y[-1] = st_start_orig.y

    # Mise à jour des stations_out avec les coords ajustées
    for idx, sid in enumerate(stations_sequence):
        st = stations_out.get(sid)
        if st is None:
            stations_out[sid] = Station(id=sid, x=X[idx], y=Y[idx], z=None)
        else:
            st.x = X[idx]
            st.y = Y[idx]

    # Recalcul des dx/dy à partir des coords finales (cohérence des segments)
    for i, seg in enumerate(segments):
        sid_from = seg.origine
        sid_to = seg.cible
        st_from = stations_out.get(sid_from)
        st_to = stations_out.get(sid_to)
        if (
            st_from is not None
            and st_to is not None
            and st_from.x is not None
            and st_to.x is not None
            and st_from.y is not None
            and st_to.y is not None
        ):
            seg.dx = st_to.x - st_from.x
            seg.dy = st_to.y - st_from.y

    return stations_out, segments, plan_closure


def _apply_vertical_adjustment(
    stations_original: Dict[str, Station],
    stations_out: Dict[str, Station],
    stations_sequence: List[str],
    segments: List[SegmentResult],
    altimetric_closure: AltimetricClosureResult,
) -> Tuple[Dict[str, Station], Dict[str, float]]:
    """
    Répartit la fermeture altimétrique fZ sur la boucle fermée
    (cheminement fermé) de manière analogue à Bowditch.

    Hypothèses :
      - stations_sequence = [S0, S1, ..., Sn, S0]
      - Z(S0) est connue dans stations_original
      - Z(S0)_bouclé est disponible dans altimetric_closure.z_computed
      - altimetric_closure.closure = Z_computed - Z_known

    On corrige les Z de tous les sommets de la boucle (sauf S0 fixé)
    proportionnellement aux distances cumulées L_j.
    Au final, Z(S0)_départ = Z(S0)_arrivée = Z_known.

    Retourne:
      - stations_out avec Z ajustées
      - vertical_corrections: dict {station_id: dZ_appliqué}
    """
    fZ = altimetric_closure.closure
    if fZ is None:
        # Pas de fermeture altimétrique calculable
        return stations_out, {}

    if not stations_sequence:
        return stations_out, {}

    start_id = stations_sequence[0]
    st_start_orig = stations_original.get(start_id)

    if st_start_orig is None or st_start_orig.z is None:
        # Pas de cote connue au départ : on ne compense pas
        return stations_out, {}

    n = len(stations_sequence)
    if n < 2 or not segments:
        return stations_out, {}

    # On mémorise les Z d'origine (avant compensation verticale)
    old_z: Dict[str, float] = {}
    for sid, st in stations_out.items():
        if st.z is not None:
            old_z[sid] = st.z

    # Distances cumulées le long de la boucle (comme pour Bowditch plani)
    L = [0.0] * n
    for j in range(1, n):
        seg = segments[j - 1]
        # on se base sur la distance horizontale si disponible, sinon di_corrected
        d_h = seg.d_h if seg.d_h is not None else seg.di_corrected or 0.0
        L[j] = L[j - 1] + d_h
    L_tot = L[-1]
    if L_tot <= 0.0:
        return stations_out, {}

    # Correction sur les cotes : Z_j += dZj  avec dZj = -fZ * (L_j / L_tot)
    for j, sid in enumerate(stations_sequence):
        st = stations_out.get(sid)
        if st is None or st.z is None:
            continue

        if j == 0:
            # Station de départ, sera réimposée à Z_known à la fin
            continue

        factor = L[j] / L_tot
        dZj = -fZ * factor
        st.z = st.z + dZj

    # On impose la cote de départ (et de fin) à la valeur connue
    z_known = st_start_orig.z
    if start_id in stations_out:
        stations_out[start_id].z = z_known
    if stations_sequence[-1] in stations_out:
        stations_out[stations_sequence[-1]].z = z_known

    # Recalcul des dZ sur les segments à partir des nouvelles cotes
    for seg in segments:
        st_from = stations_out.get(seg.origine)
        st_to = stations_out.get(seg.cible)
        if (
            st_from is not None
            and st_to is not None
            and st_from.z is not None
            and st_to.z is not None
        ):
            seg.z_from = st_from.z
            seg.z_to = st_to.z
            seg.dZ = st_to.z - st_from.z

    # Calcul des corrections verticales station par station
    vertical_corrections: Dict[str, float] = {}
    for sid, st in stations_out.items():
        if st.z is None:
            continue
        z_old = old_z.get(sid)
        if z_old is None:
            continue
        dz = st.z - z_old
        # on affiche même les corrections nulles,  sinon on peut filtrer avec un seuil.
        if abs(dz) > 1e-10:
            vertical_corrections[sid] = dz

    return stations_out, vertical_corrections


# ---------------------------------------------------------------------------
# Génération du rapport Markdown
# ---------------------------------------------------------------------------


def _format_float(value: Optional[float], ndigits: int = 4) -> str:
    if value is None:
        return ""
    return f"{value:.{ndigits}f}"


def _build_markdown_report(
    data: Dict[str, Any],
    stations_out_raw: Dict[str, Station],
    stations_out: Dict[str, Station],
    stations_sequence: List[str],
    calcul_list: List[str],
    v0_by_station_occ: Dict[Tuple[str, int], float],
    segments: List[SegmentResult],
    back_forward_checks: List[BackForwardCheck],
    angular_closure: AngularClosureResult,
    altimetric_closure: AltimetricClosureResult,
    planimetric_closure: PlanimetricClosureResult,
    vertical_corrections: Dict[str, float],
) -> str:
    meta = data.get("meta", {})
    config = data.get("config", {})

    project_name = meta.get("project_name", "")
    observer = meta.get("observer", "")
    instrument = meta.get("instrument", "")
    date = meta.get("date", "")

    # Tolérances (optionnelles)
    dist_tol = config.get("distance_back_forward_tolerance")
    dir_tol = config.get("direction_back_forward_tolerance")
    vert_tol = config.get("vertical_back_forward_tolerance")
    ang_tol = config.get("angular_closure_tolerance")
    plan_tol = config.get("planimetric_closure_tolerance")
    alt_tol = config.get("altimetric_closure_tolerance")

    # V0 summary: station, occurrence, V0
    # On suit l'ordre de stations_sequence, puis l'occurrence croissante
    order_index = {sid: idx for idx, sid in enumerate(stations_sequence)}

    v0_lines: List[str] = []
    for (sid, occ), v0 in sorted(
        v0_by_station_occ.items(),
        key=lambda item: (
            order_index.get(item[0][0], len(stations_sequence)),  # ordre de la séquence
            item[0][1],  # occurrence
        ),
    ):
        v0_lines.append(f"| {sid} | {occ} | {_format_float(v0, 4)} |")

    # Segments summary
    segment_lines_plani = []
    segment_lines_alti = []
    for seg in segments:
        segment_lines_plani.append(
            "| {frm} | {to} | {occ} | {dir} | {dh} | {di} | {dx} | {dy} |".format(
                frm=seg.origine,
                to=seg.cible,
                occ=seg.occurrence,
                dir=_format_float(seg.direction_gon, 4),
                dh=_format_float(seg.d_h, 4),
                di=_format_float(seg.di_corrected, 4),
                dx=_format_float(seg.dx, 4),
                dy=_format_float(seg.dy, 4),
            )
        )
        segment_lines_alti.append(
            "| {frm} | {to} | {occ} | {zfrom} | {zto} | {dZ} |".format(
                frm=seg.origine,
                to=seg.cible,
                occ=seg.occurrence,
                zfrom=_format_float(seg.z_from, 4),
                zto=_format_float(seg.z_to, 4),
                dZ=_format_float(seg.dZ, 4),
            )
        )

    # Final station coordinates (seulement celles dans "calcul")
    coords_lines = []
    for sid in stations_sequence:
        if calcul_list and sid not in calcul_list:
            continue
        st = stations_out.get(sid)
        if st is None:
            continue
        coords_lines.append(
            "| {sid} | {x} | {y} | {z} |".format(
                sid=sid,
                x=_format_float(st.x, 4),
                y=_format_float(st.y, 4),
                z=_format_float(st.z, 4),
            )
        )
    # Raw (unadjusted) station coordinates (avant compensation plani)
    raw_coords_lines = []
    for sid in stations_sequence:
        if calcul_list and sid not in calcul_list:
            continue
        st = stations_out_raw.get(sid)
        if st is None:
            continue
        raw_coords_lines.append(
            "| {sid} | {x} | {y} | {z} |".format(
                sid=sid,
                x=_format_float(st.x, 4),
                y=_format_float(st.y, 4),
                z=_format_float(st.z, 4),
            )
        )

    # Corrections verticales ΔZ par station
    vertical_corr_lines = []
    # On suit l'ordre de la séquence pour la lisibilité
    for sid in stations_sequence:
        if sid not in vertical_corrections:
            continue
        dz = vertical_corrections[sid]
        vertical_corr_lines.append(
            "| {sid} | {dz} |".format(
                sid=sid,
                dz=_format_float(dz, 4),
            )
        )

    # Back / forward checks, split into distances / directions / slopes
    dist_lines = []
    dir_lines = []
    slope_lines = []

    for chk in back_forward_checks:
        # Distances
        dist_lines.append(
            "| {a} | {b} | {di_ab} | {di_ba} | {d_di} | {okd} |".format(
                a=chk.origine,
                b=chk.cible,
                di_ab=_format_float(chk.di_ab, 4),
                di_ba=_format_float(chk.di_ba, 4),
                d_di=_format_float(chk.delta_di, 4),
                okd=(
                    "OK"
                    if chk.ok_distance
                    else "NOK"
                    if chk.ok_distance is not None
                    else ""
                ),
            )
        )

        # Directions
        dir_lines.append(
            "| {a} | {b} | {dir_ab} | {dir_ba} | {d_dir} | {okdir} |".format(
                a=chk.origine,
                b=chk.cible,
                dir_ab=_format_float(chk.dir_ab, 4),
                dir_ba=_format_float(chk.dir_ba, 4),
                d_dir=_format_float(chk.delta_dir_centered, 4),
                okdir=(
                    "OK"
                    if chk.ok_direction
                    else "NOK"
                    if chk.ok_direction is not None
                    else ""
                ),
            )
        )

        # Pentes
        slope_lines.append(
            "| {a} | {b} | {s_ab} | {s_ba} | {d_s} | {okv} |".format(
                a=chk.origine,
                b=chk.cible,
                s_ab=_format_float(chk.slope_ab, 6),
                s_ba=_format_float(chk.slope_ba, 6),
                d_s=_format_float(chk.delta_slope, 6),
                okv=(
                    "OK"
                    if chk.ok_vertical
                    else "NOK"
                    if chk.ok_vertical is not None
                    else ""
                ),
            )
        )

    # Construction du markdown
    md_parts: List[str] = []

    md_parts.append("# {}".format(tr("Closed polygonal traverse report")))
    md_parts.append("")
    md_parts.append("- **{}**: {}".format(tr("Project"), project_name))
    md_parts.append("- **{}**: {}".format(tr("Observer"), observer))
    md_parts.append("- **{}**: {}".format(tr("Instrument"), instrument))
    md_parts.append("- **{}**: {}".format(tr("Date"), date))
    md_parts.append("")

    # Bloc tolérances juste après les meta
    md_parts.append("### {}".format(tr("Check tolerances")))
    md_parts.append("")
    if dist_tol is not None:
        md_parts.append(
            "- **{}**: {} m".format(tr("Distance back/forward tolerance"), dist_tol)
        )
    if dir_tol is not None:
        md_parts.append(
            "- **{}**: {} {}".format(
                tr("Direction back/forward tolerance"), dir_tol, tr("gon")
            )
        )
    if vert_tol is not None:
        md_parts.append(
            "- **{}**: {}".format(tr("Vertical back/forward tolerance"), vert_tol)
        )
    if ang_tol is not None:
        md_parts.append(
            "- **{}**: {} {}".format(
                tr("Angular closure tolerance"), ang_tol, tr("gon")
            )
        )
    if plan_tol is not None:
        md_parts.append(
            "- **{}**: {} m".format(tr("Planimetric closure tolerance"), plan_tol)
        )
    if alt_tol is not None:
        md_parts.append(
            "- **{}**: {} m".format(tr("Altimetric closure tolerance"), alt_tol)
        )
    md_parts.append("")

    # Fermeture angulaire
    md_parts.append("## {}".format(tr("Angular closure")))
    md_parts.append("")
    md_parts.append("- **{}**: {}".format(tr("Station"), angular_closure.station))
    md_parts.append(
        "- **{}**: {} gon".format(
            tr("V0 (independent)"), _format_float(angular_closure.v0_independent, 4)
        )
    )
    md_parts.append(
        "- **{}**: {} gon".format(
            tr("V0 (propagated along loop)"),
            _format_float(angular_closure.v0_propagated, 4),
        )
    )
    md_parts.append(
        "- **{}**: {} gon".format(
            tr("Angular closure fα = V0_known - V0_computed"),
            _format_float(angular_closure.angular_closure, 4),
        )
    )
    md_parts.append(
        "- **{}**: {} gon".format(
            tr("Angular closure tolerance"), _format_float(angular_closure.tolerance, 4)
        )
    )
    result_str = ""
    if angular_closure.ok is True:
        result_str = "OK"
    elif angular_closure.ok is False:
        result_str = "NOK"
    md_parts.append("- **{}**: {}".format(tr("Angular closure result"), result_str))
    md_parts.append("")

    # Fermeture planimétrique
    md_parts.append("## {}".format(tr("Planimetric closure")))
    md_parts.append("")
    md_parts.append(f"- Fx = {_format_float(planimetric_closure.Fx, 4)} m")
    md_parts.append(f"- Fy = {_format_float(planimetric_closure.Fy, 4)} m")
    md_parts.append(f"- F  = {_format_float(planimetric_closure.F, 4)} m")
    md_parts.append(
        "- **{}**: {} m".format(
            tr("Planimetric closure tolerance"),
            _format_float(planimetric_closure.tolerance, 4),
        )
    )
    plan_res = ""
    if planimetric_closure.ok is True:
        plan_res = "OK"
    elif planimetric_closure.ok is False:
        plan_res = "NOK"

    md_parts.append("- **{}**: {}".format(tr("Planimetric closure result"), plan_res))
    md_parts.append("")

    # Fermeture altimétrique
    md_parts.append("## {}".format(tr("Altimetric closure")))
    md_parts.append("")
    md_parts.append("- **{}**: {}".format(tr("Station"), altimetric_closure.station))
    md_parts.append(
        "- **{}**: {} m".format(
            tr("Known Z"), _format_float(altimetric_closure.z_known, 4)
        )
    )
    md_parts.append(
        "- **{}**: {} m".format(
            tr("Computed Z after loop"), _format_float(altimetric_closure.z_computed, 4)
        )
    )
    md_parts.append(
        "- **{}**: {} m".format(
            tr("Altimetric closure fZ = Z_computed - Z_known"),
            _format_float(altimetric_closure.closure, 4),
        )
    )
    md_parts.append(
        "- **{}**: {} m".format(
            tr("Altimetric closure tolerance"),
            _format_float(altimetric_closure.tolerance, 4),
        )
    )
    alt_res = ""
    if altimetric_closure.ok is True:
        alt_res = "OK"
    elif altimetric_closure.ok is False:
        alt_res = "NOK"
    md_parts.append("- **{}**: {}".format(tr("Altimetric closure result"), alt_res))
    md_parts.append("")

    # Fermeture altimétrique (déjà affichée ici)

    # Corrections verticales appliquées
    md_parts.append("## {}".format(tr("Vertical corrections (ΔZ)")))
    md_parts.append("")
    md_parts.append("| Station | ΔZ (m) |")
    md_parts.append("|---------|--------|")
    md_parts.extend(vertical_corr_lines or ["| | |"])
    md_parts.append("")

    md_parts.append("## {}".format(tr("Station sequence")))
    md_parts.append("")
    md_parts.append(", ".join(stations_sequence))
    md_parts.append("")

    md_parts.append("## {}".format(tr("Orientation (V0) per station / occurrence")))
    md_parts.append("")
    md_parts.append(tr("| Station | Occurrence | V0 (gon) |"))
    md_parts.append("|---------|------------|----------|")
    md_parts.extend(v0_lines or ["| | | |"])
    md_parts.append("")
    md_parts.append("## {}".format(tr("Raw station coordinates (unadjusted)")))
    md_parts.append("")
    md_parts.append("| Station | X (m) | Y (m) | Z (m) |")
    md_parts.append("|---------|-------|-------|-------|")
    md_parts.extend(raw_coords_lines or ["| | | | |"])
    md_parts.append("")

    md_parts.append("## {}".format(tr("Final station coordinates (adjusted)")))
    md_parts.append("")
    md_parts.append("| Station | X (m) | Y (m) | Z (m) |")
    md_parts.append("|---------|-------|-------|-------|")
    md_parts.extend(coords_lines or ["| | | | |"])
    md_parts.append("")

    md_parts.append("## {}".format(tr("Traverse segments")))
    md_parts.append("")
    md_parts.append(
        tr(
            "| From | To | Occurrence | Direction (gon) | dH (m) | d (m) | dX (m) | dY (m) |"
        )
    )
    md_parts.append(
        "|------|----|------------|-----------------|--------|-------|---------|---------|"
    )
    md_parts.extend(segment_lines_plani or ["| | | | | | | | |"])
    md_parts.append("")

    md_parts.append("")
    md_parts.append(tr("| From | To | Occurrence | Z_from (m) | Z_to (m) | dZ (m) |"))
    md_parts.append("|------|----|------------|-----------------|--------|-------|")
    md_parts.extend(segment_lines_alti or ["| | | | | | |"])
    md_parts.append("")

    md_parts.append("## {}".format(tr("Final station coordinates")))
    md_parts.append("")
    md_parts.append("| Station | X (m) | Y (m) | Z (m) |")
    md_parts.append("|---------|-------|-------|-------|")
    md_parts.extend(coords_lines or ["| | | | |"])
    md_parts.append("")

    # --- Back / forward checks: distances ---
    md_parts.append("## {}".format(tr("Back / forward checks – distances")))
    md_parts.append("")
    md_parts.append(tr("| From | To | d(AB) (m) | d(BA) (m) | Δd (m) | Dist OK? |"))
    md_parts.append("|------|----|-----------|-----------|---------|----------|")
    md_parts.extend(dist_lines or ["| | | | | |"])
    md_parts.append("")

    # --- Back / forward checks: directions ---
    md_parts.append("## {}".format(tr("Back / forward checks – directions")))
    md_parts.append("")
    md_parts.append(
        tr(
            "| From | To | Dir_AB (gon) | Dir_BA (gon) | ΔDir centered (gon) | Dir OK? |"
        )
    )
    md_parts.append(
        "|------|----|--------------|--------------|----------------------|---------|"
    )
    md_parts.extend(dir_lines or ["| | | | | |"])
    md_parts.append("")

    # --- Back / forward checks: slopes ---
    md_parts.append("## {}".format(tr("Back / forward checks – slopes")))
    md_parts.append("")
    md_parts.append(tr("| From | To | Slope_AB | Slope_BA | ΔSlope | Vert OK? |"))
    md_parts.append("|------|----|----------|----------|--------|----------|")
    md_parts.extend(slope_lines or ["| | | | | |"])
    md_parts.append("")

    return "\n".join(md_parts)


# ---------------------------------------------------------------------------
# Classe ClosedTraverse avec méthode de classe
# ---------------------------------------------------------------------------


class ClosedTraverse:
    """
    Helper class for computing a closed polygonal traverse
    (cheminement polygonal fermé).

    Main entry point:
        ClosedTraverse.compute_closed_traverse_from_json(json_path, output_dir)
    """

    @classmethod
    def compute_closed_traverse_from_json(
        cls,
        json_path: Path,
        output_dir: Path,
    ) -> Dict[str, Any]:
        """
        Calcule un cheminement polygonal fermé en mode goniométrique
        à partir d'un fichier JSON (par ex. 'closed_path.json').

        Étapes:
          - lecture du JSON,
          - agrégation des observations (compensation CG/CD, moyennes),
          - calcul du V0 par station / occurrence:
              * v0 forcé dans obs si présent,
              * sinon à partir des visées de type "reference",
              * sinon recalcul local par visées aller/retour,
              * sinon transmission depuis la station précédente,
          - propagation du cheminement XY (+ Z si demandé),
          - contrôles aller/retour (distance, direction, vertical),
          - calcul de la fermeture angulaire sur la station de départ/arrivée,
          - répartition de la fermeture angulaire sur les côtés,
          - calcul de la fermeture planimétrique et compensation Bowditch,
          - génération d'un rapport Markdown 'cheminement_ferme.md',
          - conversion éventuelle en HTML, PDF et ODT si les fonctions
            markdown_to_html / markdown_to_pdf / markdown_to_odt sont disponibles.

        Retourne un dict contenant les résultats et les chemins de rapport.
        """
        output_dir.mkdir(parents=True, exist_ok=True)

        data = _load_closed_json(json_path)
        stations = _parse_stations(data)
        stations = _ensure_all_stations_defined(stations, data)
        observations = _parse_observations(data)

        config = data.get("config", {})
        distance_tol = float(config.get("distance_back_forward_tolerance", 0.005))
        direction_tol = float(config.get("direction_back_forward_tolerance", 0.002))
        vertical_slope_tol = float(config.get("vertical_back_forward_tolerance", 0.002))
        angular_tol = config.get("angular_closure_tolerance")
        angular_tol_f = float(angular_tol) if angular_tol is not None else None

        planimetric_tol = config.get("planimetric_closure_tolerance")
        planimetric_tol_f = (
            float(planimetric_tol) if planimetric_tol is not None else None
        )

        compute_z = bool(config.get("compute_z", True))
        linear_scale_factor = float(config.get("linear_scale_factor", 1.0))

        altimetric_tol = config.get("altimetric_closure_tolerance")
        altimetric_tol_f = float(altimetric_tol) if altimetric_tol is not None else None

        stations_sequence = data.get("stations_sequence", [])
        if not stations_sequence:
            raise ValueError(tr("'stations_sequence' is missing or empty"))

        # Pour un cheminement fermé, on s'attend à ce que la séquence contienne
        # la station de départ deux fois: ["S1", "S2", ..., "Sn", "S1"].
        if stations_sequence[0] != stations_sequence[-1]:
            raise ValueError(
                tr(
                    "For a closed traverse, 'stations_sequence' must start and end with the same station"
                )
            )

        calcul_list = data.get("calcul", [])

        # 1) Agrégation des observations
        aggregated, aggregated_by_station_occ = _aggregate_observations(
            observations, linear_scale_factor
        )

        # 2) Calcul des V0 par station / occurrence
        v0_by_station_occ = _compute_v0_by_station_occ(
            stations=stations,
            aggregated_by_station_occ=aggregated_by_station_occ,
            stations_sequence=stations_sequence,
        )

        # 3) Calcul des segments (cheminement XY/Z) - bruts
        stations_out_raw, segments = _compute_segments(
            stations=stations,
            stations_sequence=stations_sequence,
            aggregated=aggregated,
            v0_by_station_occ=v0_by_station_occ,
            compute_z=compute_z,
        )

        # 3bis) Fermeture altimétrique (sur Z bruts)
        altimetric_closure = _compute_altimetric_closure(
            stations_sequence=stations_sequence,
            stations_original=stations,
            stations_out_raw=stations_out_raw,
            alt_tol=altimetric_tol_f,
        )

        # 4) Contrôles aller / retour (sur données brutes)
        back_forward_checks = _compute_back_forward_checks(
            aggregated=aggregated,
            v0_by_station_occ=v0_by_station_occ,
            distance_tol=distance_tol,
            direction_tol=direction_tol,
            vertical_slope_tol=vertical_slope_tol,
        )

        # 5) Fermeture angulaire (sur données brutes)
        angular_closure = _compute_angular_closure(
            stations_sequence=stations_sequence,
            aggregated_by_station_occ=aggregated_by_station_occ,
            v0_by_station_occ=v0_by_station_occ,
            angular_tol=angular_tol_f,
        )

        (
            stations_out,
            segments,
            planimetric_closure,
        ) = _apply_angular_and_planimetric_adjustments(
            stations_original=stations,
            stations_out_raw=stations_out_raw,
            stations_sequence=stations_sequence,
            segments=segments,
            angular_closure=angular_closure,
            planimetric_tol=planimetric_tol_f,
        )

        # 6bis) Compensation verticale (Bowditch en Z sur la boucle)
        stations_out, vertical_corrections = _apply_vertical_adjustment(
            stations_original=stations,
            stations_out=stations_out,
            stations_sequence=stations_sequence,
            segments=segments,
            altimetric_closure=altimetric_closure,
        )

        # 7) Rapport Markdown (avec coords & directions compensées)
        md_content = _build_markdown_report(
            data=data,
            stations_out_raw=stations_out_raw,
            stations_out=stations_out,
            stations_sequence=stations_sequence,
            calcul_list=calcul_list,
            v0_by_station_occ=v0_by_station_occ,
            segments=segments,
            back_forward_checks=back_forward_checks,
            angular_closure=angular_closure,
            altimetric_closure=altimetric_closure,
            planimetric_closure=planimetric_closure,
            vertical_corrections=vertical_corrections,
        )

        md_path = output_dir / "cheminement_ferme.md"
        md_path.write_text(md_content, encoding="utf-8")

        # 8) Conversions HTML, PDF, ODT (si dispo)
        html_path = output_dir / "cheminement_ferme.html"
        pdf_path = output_dir / "cheminement_ferme.pdf"
        odt_path = output_dir / "cheminement_ferme.odt"

        try:
            ReportUtils.markdown_to_html(md_path, html_path)  # type: ignore[arg-type]
        except TypeError:
            ReportUtils.markdown_to_html(md_path)  # type: ignore[arg-type]

        try:
            ReportUtils.markdown_to_pdf(md_path, pdf_path)  # type: ignore[arg-type]
        except TypeError:
            ReportUtils.markdown_to_pdf(md_path)  # type: ignore[arg-type]

        try:
            ReportUtils.markdown_to_odt(md_path, odt_path)  # type: ignore[arg-type]
        except TypeError:
            ReportUtils.markdown_to_odt(md_path)  # type: ignore[arg-type]

        return {
            "stations_raw": stations_out_raw,
            "stations": stations_out,
            "v0_by_station_occ": v0_by_station_occ,
            "segments": segments,
            "back_forward_checks": back_forward_checks,
            "angular_closure": angular_closure,
            "altimetric_closure": altimetric_closure,
            "planimetric_closure": planimetric_closure,
            "vertical_corrections": vertical_corrections,
            "report_paths": {
                "md": md_path,
                "html": html_path if html_path.exists() else None,
                "pdf": pdf_path if pdf_path.exists() else None,
                "odt": odt_path if odt_path.exists() else None,
            },
        }


if __name__ == "__main__":
    # Exemple d'appel direct pour tests:
    ClosedTraverse.compute_closed_traverse_from_json(
        Path("c:/temp/closed_path.json"),
        Path("c:/temp"),
    )
    pass
