#!/usr/bin/env python3
"""
4_yAxis_rotation_reconstruction.py
====================================
Berechnet die Rotationsachse eines Gelenks aus drei Board-Erkennungs-Timestamps.

Gegeben drei Messungen (Pos A, B, C) – in denen dieselben fremd-Marker
(link != 'Board') erkannt wurden – bestimmt das Skript:
  - Richtung der Rotationsachse  (axisDir, Einheitsvektor)
  - Referenzpunkt auf der Achse  (axisPoint_mm)
  - Residuen pro Punkt           (Qualitätsmass)

Methode (doc/Kalibrierung.md → [4] Arm1, Verfahren B):
  Jeder Marker bewegt sich auf einem Kreisbogen. Die Ebene des Kreises
  steht senkrecht zur Rotationsachse → Normalenvektor = Achsenrichtung.
  Der Umkreismittelpunkt des Dreiecks P1-P2-P3 liegt auf der Achse.

  Statt nur dem Marker-Zentrum werden alle vier Ecken (corners_m) verwendet:
  das liefert 4× mehr Datenpunkte und macht die Schätzung robuster.

Usage:
  python3 4_yAxis_rotation_reconstruction.py <posA> <posB> <posC> [Optionen]

  <posA/B/C>  Verzeichnis mit aruco_marker_poses.json  ODER  direkter Dateipfad

Optionen:
  --output, -o  Ergebnis in JSON-Datei speichern
  --pretty      Eingerücktes JSON auf stdout
  --link        Marker-Link, der als "Referenz" gilt und NICHT verwendet wird
                (Default: 'Board')

Output (stdout):
  JSON mit axisDir, axisPoint_mm, residuals, tiltXY_deg, tiltYZ_deg, …
"""
from __future__ import annotations

import argparse
import json
import math
import os
import sys
from typing import List, Optional, Tuple

import numpy as np


# ── Laden ─────────────────────────────────────────────────────────────────────

def load_poses(path: str) -> dict:
    """Lädt aruco_marker_poses.json – akzeptiert Verzeichnis oder direkten Pfad."""
    if os.path.isdir(path):
        path = os.path.join(path, 'aruco_marker_poses.json')
    if not os.path.exists(path):
        raise FileNotFoundError(f'Nicht gefunden: {path}')
    with open(path, 'r', encoding='utf-8') as f:
        return json.load(f)


def fremd_markers(data: dict, ref_link: str = 'Board') -> dict[int, dict]:
    """Gibt fremd-Marker als {marker_id: marker_dict} zurück."""
    return {
        m['marker_id']: m
        for m in data.get('markers', [])
        if m.get('link') != ref_link
    }


def get_points_mm(marker: dict) -> List[np.ndarray]:
    """
    Gibt alle 3D-Punkte eines Markers in mm zurück.
    Bevorzugt corners_m (4 Ecken); Fallback: Zentrum position_mm.
    """
    corners = marker.get('corners_m')
    if corners:
        return [np.array(c, dtype=float) * 1000.0 for c in corners]
    pos = marker.get('position_mm')
    if pos:
        return [np.array(pos, dtype=float)]
    pos_m = marker.get('position_m')
    if pos_m:
        return [np.array(pos_m, dtype=float) * 1000.0]
    return []


# ── Geometrie ─────────────────────────────────────────────────────────────────

def circumcenter_and_normal(
    P1: np.ndarray, P2: np.ndarray, P3: np.ndarray
) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], float]:
    """
    Umkreismittelpunkt + Normalenvektor des Dreiecks P1-P2-P3.

    Rückgabe: (circumcenter_mm, normal, cross_length)
    Bei Degeneration (kollinear / identisch): (None, None, cross_length)
    """
    v1 = P2 - P1
    v2 = P3 - P1
    cross = np.cross(v1, v2)
    cross_len = float(np.linalg.norm(cross))

    if cross_len < 1e-6:          # Punkte (fast) kollinear oder identisch
        return None, None, cross_len

    n = cross / cross_len

    # Baryzentrische Gewichte → Umkreismittelpunkt
    a2 = float(np.dot(P2 - P3, P2 - P3))
    b2 = float(np.dot(P1 - P3, P1 - P3))
    c2 = float(np.dot(P1 - P2, P1 - P2))

    w1 = a2 * (b2 + c2 - a2)
    w2 = b2 * (a2 + c2 - b2)
    w3 = c2 * (a2 + b2 - c2)
    w_sum = w1 + w2 + w3

    if abs(w_sum) < 1e-12:        # gleichseitiges / entartetes Dreieck
        return None, None, cross_len

    C = (w1 * P1 + w2 * P2 + w3 * P3) / w_sum
    return C, n, cross_len


def point_line_distance(point: np.ndarray, line_pt: np.ndarray, line_dir: np.ndarray) -> float:
    """Abstand Punkt → Gerade (line_pt + t·line_dir, line_dir normiert)."""
    diff = point - line_pt
    return float(np.linalg.norm(diff - np.dot(diff, line_dir) * line_dir))


# ── Kernberechnung ────────────────────────────────────────────────────────────

def compute_rotation_axis(
    posA: dict,
    posB: dict,
    posC: dict,
    ref_link: str = 'Board',
    min_radius_mm: float = 0.5,
    min_movement_mm: float = 10.0,
) -> dict:
    """
    Berechnet die Rotationsachse aus drei Messungen.

    Parameter:
      min_radius_mm   – Kreisradius unter dem ein Ecken-Triplet als degenerat gilt
      min_movement_mm – Minimale Zentren-Bewegung (max Paarweisabstand A/B/C)
                        Marker die sich weniger bewegen werden ignoriert.
                        Default 10 mm → filtert Board-nahe / fest stehende Marker.
    """
    mA = fremd_markers(posA, ref_link)
    mB = fremd_markers(posB, ref_link)
    mC = fremd_markers(posC, ref_link)

    common_ids = sorted(set(mA) & set(mB) & set(mC))
    if not common_ids:
        return {'ok': False, 'error': 'Keine gemeinsamen fremd-Marker in A+B+C'}

    circumcenters: List[np.ndarray] = []
    normals:       List[np.ndarray] = []
    skipped:       List[dict]       = []
    marker_results: List[dict]      = []

    for mid in common_ids:
        # ── Mindest-Bewegungs-Filter ───────────────────────────────────────────
        # Marker die sich kaum bewegen liefern degenerate Umkreismittelpunkte.
        # Wir vergleichen die Zentren (position_mm) der drei Messungen.
        cA = np.array(mA[mid].get('position_mm', [0, 0, 0]), dtype=float)
        cB = np.array(mB[mid].get('position_mm', [0, 0, 0]), dtype=float)
        cC = np.array(mC[mid].get('position_mm', [0, 0, 0]), dtype=float)
        max_movement = max(
            np.linalg.norm(cB - cA),
            np.linalg.norm(cC - cB),
            np.linalg.norm(cC - cA),
        )
        if max_movement < min_movement_mm:
            skipped.append({
                'marker_id': mid,
                'reason': 'Bewegung zu gering (kein rotierender Marker)',
                'max_movement_mm': round(float(max_movement), 2),
                'threshold_mm': min_movement_mm,
            })
            continue

        pts_a = get_points_mm(mA[mid])
        pts_b = get_points_mm(mB[mid])
        pts_c = get_points_mm(mC[mid])

        n_pts = min(len(pts_a), len(pts_b), len(pts_c))
        ok_pts: List[np.ndarray] = []
        ok_ns:  List[np.ndarray] = []

        for i in range(n_pts):
            P1, P2, P3 = pts_a[i], pts_b[i], pts_c[i]
            C, n, cross_len = circumcenter_and_normal(P1, P2, P3)

            if C is None:
                skipped.append({
                    'marker_id': mid, 'point_idx': i,
                    'reason': 'degenerat (kollinear)',
                    'cross_len': round(cross_len, 6),
                })
                continue

            radius = float(np.linalg.norm(C - P1))
            if radius < min_radius_mm:
                skipped.append({
                    'marker_id': mid, 'point_idx': i,
                    'reason': 'radius zu klein (Bewegung zu gering)',
                    'radius_mm': round(radius, 4),
                })
                continue

            ok_pts.append(C)
            ok_ns.append(n)
            circumcenters.append(C)
            normals.append(n)

        cc_arr = np.array(ok_pts) if ok_pts else np.empty((0, 3))
        marker_results.append({
            'marker_id':       mid,
            'n_points_used':   len(ok_pts),
            'n_points_total':  n_pts,
            'circumcenter_mean_mm': cc_arr.mean(axis=0).round(3).tolist()
            if len(ok_pts) else None,
        })

    if not normals:
        return {
            'ok': False,
            'error': 'Alle Triplets degenerat – Bewegung zu gering oder Marker kollinear.',
            'skipped': skipped,
        }

    normals_arr      = np.array(normals)          # (N, 3)
    circumcenters_arr = np.array(circumcenters)   # (N, 3)

    # ── Achsenrichtung ─────────────────────────────────────────────────────────
    # Alle Normalen auf dieselbe Halbkugel (Vorzeichenanpassung)
    ref = normals_arr[0]
    signs = np.where(normals_arr @ ref >= 0, 1.0, -1.0)
    aligned = normals_arr * signs[:, np.newaxis]

    mean_n   = aligned.mean(axis=0)
    axis_dir = mean_n / np.linalg.norm(mean_n)

    # Streuung der Normalen (Winkelresidum)
    cos_angles = np.clip(aligned @ axis_dir, -1, 1)
    angle_residuals_deg = np.degrees(np.arccos(cos_angles))

    # ── Referenzpunkt ──────────────────────────────────────────────────────────
    axis_point = circumcenters_arr.mean(axis=0)

    # ── Abstandsresiduen ───────────────────────────────────────────────────────
    dist_residuals = np.array([
        point_line_distance(C, axis_point, axis_dir)
        for C in circumcenters_arr
    ])

    # ── Abweichung von Y-Achse [0, 1, 0] in Roboter-Koordinaten ──────────────
    ax, ay, az = axis_dir
    tilt_xy_deg = math.degrees(math.atan2(ax, ay))   # Kippung in XY-Ebene
    tilt_yz_deg = math.degrees(math.atan2(az, ay))   # Kippung in YZ-Ebene

    used_ids = [r['marker_id'] for r in marker_results if r['n_points_used'] > 0]

    return {
        'ok':                   True,
        'axisDir':              axis_dir.round(6).tolist(),
        'axisPoint_mm':         axis_point.round(3).tolist(),
        'tiltXY_deg':           round(tilt_xy_deg, 4),
        'tiltYZ_deg':           round(tilt_yz_deg, 4),
        'numMarkersUsed':       len(used_ids),
        'numMarkersCommon':     len(common_ids),
        'usedMarkerIds':        used_ids,
        'commonMarkerIds':      common_ids,
        'numPoints':            len(normals),
        'residual_dist_mean_mm':  round(float(dist_residuals.mean()), 3),
        'residual_dist_max_mm':   round(float(dist_residuals.max()),  3),
        'residual_dist_mm':       dist_residuals.round(3).tolist(),
        'residual_angle_mean_deg': round(float(angle_residuals_deg.mean()), 4),
        'residual_angle_max_deg':  round(float(angle_residuals_deg.max()),  4),
        'markerResults':        marker_results,
        'skipped':              skipped,
    }


# ── CLI ───────────────────────────────────────────────────────────────────────

def main() -> None:
    parser = argparse.ArgumentParser(
        description='Rotationsachse aus drei Board-Timestamps berechnen'
    )
    parser.add_argument('posA', help='Timestamp A – Verzeichnis oder aruco_marker_poses.json')
    parser.add_argument('posB', help='Timestamp B – Verzeichnis oder aruco_marker_poses.json')
    parser.add_argument('posC', help='Timestamp C – Verzeichnis oder aruco_marker_poses.json')
    parser.add_argument('--output', '-o', metavar='FILE',
                        help='Ergebnis zusätzlich in JSON-Datei speichern')
    parser.add_argument('--pretty', action='store_true',
                        help='Eingerücktes JSON auf stdout')
    parser.add_argument('--link', default='Board',
                        help='Referenz-Link der Board-Marker (Default: Board)')
    parser.add_argument('--min-radius', type=float, default=0.5,
                        help='Min. Kreisradius mm (Default 0.5)')
    parser.add_argument('--min-movement', type=float, default=10.0,
                        help='Min. Marker-Zentrumsbewegung mm (Default 10.0)')
    args = parser.parse_args()

    try:
        data_a = load_poses(args.posA)
        data_b = load_poses(args.posB)
        data_c = load_poses(args.posC)
    except FileNotFoundError as e:
        print(f'Fehler: {e}', file=sys.stderr)
        sys.exit(1)

    result = compute_rotation_axis(data_a, data_b, data_c,
                                   ref_link=args.link,
                                   min_radius_mm=args.min_radius,
                                   min_movement_mm=args.min_movement)

    indent = 2 if args.pretty else None
    out_str = json.dumps(result, indent=indent, ensure_ascii=False)
    print(out_str)

    if args.output:
        with open(args.output, 'w', encoding='utf-8') as f:
            f.write(out_str)
        print(f'Ergebnis gespeichert: {args.output}', file=sys.stderr)

    if result['ok']:
        r = result
        fmt = lambda v: f'{v:+.3f}°'
        print('', file=sys.stderr)
        print('─── Rotationsachse ───────────────────────────────────────', file=sys.stderr)
        print(f"  Richtung:      [{', '.join(f'{v:.4f}' for v in r['axisDir'])}]", file=sys.stderr)
        print(f"  Referenzpunkt: [{', '.join(f'{v:.1f}' for v in r['axisPoint_mm'])}] mm", file=sys.stderr)
        print(f"  Abw. von Y:    XY={fmt(r['tiltXY_deg'])}  YZ={fmt(r['tiltYZ_deg'])}", file=sys.stderr)
        print(f"  Marker: {r['numMarkersUsed']} genutzt / {r['numMarkersCommon']} gemeinsam  "
              f"Punkte: {r['numPoints']}", file=sys.stderr)
        print(f"  Residuen Abstand: ⌀{r['residual_dist_mean_mm']:.2f} mm  "
              f"max={r['residual_dist_max_mm']:.2f} mm", file=sys.stderr)
        print(f"  Residuen Winkel:  ⌀{r['residual_angle_mean_deg']:.3f}°  "
              f"max={r['residual_angle_max_deg']:.3f}°", file=sys.stderr)
        print('──────────────────────────────────────────────────────────', file=sys.stderr)
    else:
        print(f"Fehler: {result['error']}", file=sys.stderr)
        sys.exit(1)


if __name__ == '__main__':
    main()
