// lib/utils/tcx_activity_parser.dart
import 'package:aves/model/activity/activity_track.dart';
import 'package:latlong2/latlong.dart';
import 'package:xml/xml.dart';

import 'gps_smoothing.dart';

class TcxActivityParser {
  static final Distance _distance = Distance();

  static ActivityTrack parse(String xml, {required String fileName}) {
    final doc = XmlDocument.parse(xml);

    final points = <_TcxPoint>[];
    final displayTracks = <List<LatLng>>{};

    final activityName = _readActivityName(doc, fileName);
    final sport = _readSport(doc);

    double? fileDistanceMeters;
    double? fileElevationGainMeters;

    // Nei TCX la struttura tipica e':
    // Activity -> Lap -> Track -> Trackpoint.
    // Mantengo un segmento display per ogni Track, cosi non unisco pezzi separati.
    for (final track in doc.findAllElements('Track')) {
      final segmentPoints = <_TcxPoint>[];

      for (final tp in track.findElements('Trackpoint')) {
        final lat = _readDouble(tp, const [
          'LatitudeDegrees',
        ]);

        final lon = _readDouble(tp, const [
          'LongitudeDegrees',
        ]);

        final ele = _readDouble(tp, const [
          'AltitudeMeters',
        ]);

        final time = _readDateTime(tp, const [
          'Time',
        ]);

        final distance = _readDouble(tp, const [
          'DistanceMeters',
        ]);

        if (lat == null || lon == null) continue;
        if (!lat.isFinite || !lon.isFinite) continue;
        if (lat.abs() > 90 || lon.abs() > 180) continue;

        final point = _TcxPoint(
          latLng: LatLng(lat, lon),
          elevationMeters: ele,
          time: time,
          distanceMeters: distance,
        );

        points.add(point);
        segmentPoints.add(point);
      }

      if (segmentPoints.isNotEmpty) {
        final latLngs = segmentPoints.map((p) => p.latLng).toList();
        final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0);
        displayTracks.add(smoothed);
      }
    }

    // Fallback per TCX non standard: se non esistono <Track>, leggo tutti i
    // Trackpoint globali in un unico segmento.
    if (points.isEmpty) {
      final segmentPoints = <_TcxPoint>[];

      for (final tp in doc.findAllElements('Trackpoint')) {
        final lat = _readDouble(tp, const ['LatitudeDegrees']);
        final lon = _readDouble(tp, const ['LongitudeDegrees']);
        final ele = _readDouble(tp, const ['AltitudeMeters']);
        final time = _readDateTime(tp, const ['Time']);
        final distance = _readDouble(tp, const ['DistanceMeters']);

        if (lat == null || lon == null) continue;
        if (!lat.isFinite || !lon.isFinite) continue;
        if (lat.abs() > 90 || lon.abs() > 180) continue;

        final point = _TcxPoint(
          latLng: LatLng(lat, lon),
          elevationMeters: ele,
          time: time,
          distanceMeters: distance,
        );

        points.add(point);
        segmentPoints.add(point);
      }

      if (segmentPoints.isNotEmpty) {
        final latLngs = segmentPoints.map((p) => p.latLng).toList();
        final smoothed = GpsSmoothing.douglasPeucker(latLngs, 5.0);
        displayTracks.add(smoothed);
      }
    }

    points.sort(_compareByTimeKeepingNullsLast);

    fileDistanceMeters = _readFileDistanceMeters(doc) ?? _distanceFromTrackpointField(points);
    fileElevationGainMeters = _readFileElevationGainMeters(doc);

    final computedDistanceMeters = _computeDistanceFromPoints(points);
    final computedElevationGainMeters = _computeElevationLikeCommercialApps(points);
    final computedDuration = _computeDuration(points);
    final computedStartTime = _computeStartTime(points);

    final finalDistanceMeters = _chooseDistanceMeters(
      fileDistanceMeters: fileDistanceMeters,
      computedDistanceMeters: computedDistanceMeters,
    );

    final finalElevationGainMeters = _chooseElevationGainMeters(
      fileElevationGainMeters: fileElevationGainMeters,
      computedElevationGainMeters: computedElevationGainMeters,
    );

    return ActivityTrack(
      name: fileName,
      format: 'TCX',
      tracks: displayTracks,
      routeName: activityName,
      pointCount: points.length,
      distanceMeters: finalDistanceMeters,
      elevationGainMeters: finalElevationGainMeters,
      fileDistanceMeters: fileDistanceMeters,
      fileElevationGainMeters: fileElevationGainMeters,
      computedDistanceMeters: computedDistanceMeters,
      computedElevationGainMeters: computedElevationGainMeters,
      duration: computedDuration,
      startTime: computedStartTime,
      sport: sport,
      diagnostics: [
        'fileDistanceMeters=$fileDistanceMeters',
        'computedDistanceMeters=$computedDistanceMeters',
        'finalDistanceMeters=$finalDistanceMeters',
        'fileElevationGainMeters=$fileElevationGainMeters',
        'computedElevationGainMeters=$computedElevationGainMeters',
        'finalElevationGainMeters=$finalElevationGainMeters',
        'elevationPointCount=${points.where((p) => p.elevationMeters != null).length}',
        'elevationMin=${_minElevation(points)}',
        'elevationMax=${_maxElevation(points)}',
        'originalPoints=${points.length}',
        'displayPoints=${displayTracks.expand((segment) => segment).length}',
        'routeName=$activityName',
        'sport=$sport',
      ].join('\n'),
    );
  }

  static String? _readActivityName(XmlDocument doc, String fileName) {
    // TCX spesso usa Activity/Id come data/identificativo.
    final id = doc
        .findAllElements('Activity')
        .expand((activity) => activity.findElements('Id'))
        .map((e) => e.text.trim())
        .firstWhereOrNull((value) => value.isNotEmpty);

    if (id != null) return id;

    // Alcuni export inseriscono un nome in Name o Notes.
    final name = doc
        .findAllElements('Name')
        .map((e) => e.text.trim())
        .firstWhereOrNull((value) => value.isNotEmpty);

    if (name != null) return name;

    final cleaned = fileName.trim();
    if (cleaned.isEmpty) return null;

    final dotIndex = cleaned.lastIndexOf('.');
    if (dotIndex > 0) return cleaned.substring(0, dotIndex);

    return cleaned;
  }

  static String? _readSport(XmlDocument doc) {
    final sport = doc
        .findAllElements('Activity')
        .map((e) => e.getAttribute('Sport')?.trim())
        .whereType<String>()
        .firstWhereOrNull((value) => value.isNotEmpty);

    return _normalizeSport(sport);
  }

  static double? _readFileDistanceMeters(XmlDocument doc) {
    // Nei TCX il dato aggregato normalmente si trova in Lap/DistanceMeters.
    final lapDistances = doc
        .findAllElements('Lap')
        .map((lap) => _readDirectDouble(lap, const ['DistanceMeters']))
        .whereType<double>()
        .where((v) => v.isFinite && v > 0)
        .toList();

    if (lapDistances.isNotEmpty) {
      final total = lapDistances.fold<double>(0, (sum, value) => sum + value);
      return _normalizeDistanceMeters(total);
    }

    return null;
  }

  static double? _readFileElevationGainMeters(XmlDocument doc) {
    // TCX standard Garmin non contiene sempre TotalAscent, ma alcuni export
    // custom lo aggiungono. Se c'e' ed e' plausibile, lo uso come valore file.
    final values = <double>[];

    for (final name in const [
      'TotalAscent',
      'TotalAscentMeters',
      'TotalElevationGain',
      'Ascent',
    ]) {
      values.addAll(
        doc
            .findAllElements(name)
            .map((e) => double.tryParse(e.text.trim().replaceAll(',', '.')))
            .whereType<double>()
            .where((v) => v.isFinite && v > 0),
      );
    }

    if (values.isEmpty) return null;

    // Se sono presenti piu' lap/valori, sommo. Se e' un solo valore, torna quello.
    final total = values.fold<double>(0, (sum, value) => sum + value);
    return _normalizeElevationGain(total);
  }

  static double? _distanceFromTrackpointField(List<_TcxPoint> points) {
    final values = points
        .map((p) => p.distanceMeters)
        .whereType<double>()
        .where((v) => v.isFinite && v > 0)
        .toList();

    if (values.length < 2) return null;

    values.sort();
    final distance = values.last - values.first;
    return _normalizeDistanceMeters(distance > 0 ? distance : values.last);
  }

  static double? _chooseDistanceMeters({
    required double? fileDistanceMeters,
    required double? computedDistanceMeters,
  }) {
    final file = _normalizeDistanceMeters(fileDistanceMeters);
    if (file != null) return file;

    return _normalizeDistanceMeters(computedDistanceMeters);
  }

  static double? _chooseElevationGainMeters({
    required double? fileElevationGainMeters,
    required double? computedElevationGainMeters,
  }) {
    final file = _normalizeElevationGain(fileElevationGainMeters);
    if (file != null) return file;

    return _normalizeElevationGain(computedElevationGainMeters);
  }

  static double? _normalizeDistanceMeters(double? value) {
    if (value == null || !value.isFinite || value <= 0) return null;

    const maxPlausibleMeters = 500000.0;

    if (value > maxPlausibleMeters && value / 100 <= maxPlausibleMeters) {
      return value / 100;
    }

    if (value > maxPlausibleMeters) return null;

    return value;
  }

  static double? _normalizeElevationGain(double? value) {
    if (value == null || !value.isFinite || value <= 0) return null;
    if (value > 20000) return null;
    return value;
  }

  static double? _readDouble(XmlElement parent, List<String> names) {
    for (final name in names) {
      final normalizedName = _normalizeXmlName(name);

      // Uso findAllElements perche' nei TCX lat/lon sono normalmente dentro:
      // Trackpoint -> Position -> LatitudeDegrees / LongitudeDegrees.
      for (final element in parent.findAllElements('*')) {
        if (_normalizeXmlName(element.name.local) == normalizedName) {
          final value = double.tryParse(element.text.trim().replaceAll(',', '.'));
          if (value != null) return value;
        }
      }
    }

    return null;
  }

  static double? _readDirectDouble(XmlElement parent, List<String> names) {
    for (final name in names) {
      final normalizedName = _normalizeXmlName(name);

      for (final child in parent.childElements) {
        if (_normalizeXmlName(child.name.local) == normalizedName) {
          final value = double.tryParse(child.text.trim().replaceAll(',', '.'));
          if (value != null) return value;
        }
      }
    }

    return null;
  }

  static DateTime? _readDateTime(XmlElement parent, List<String> names) {
    for (final name in names) {
      final normalizedName = _normalizeXmlName(name);

      for (final element in parent.findAllElements('*')) {
        if (_normalizeXmlName(element.name.local) == normalizedName) {
          final value = DateTime.tryParse(element.text.trim());
          if (value != null) return value.toUtc();
        }
      }
    }

    return null;
  }

  static String _normalizeXmlName(String value) {
    return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
  }

  static double? _computeDistanceFromPoints(List<_TcxPoint> points) {
    if (points.length < 2) return null;

    double dist = 0;

    for (int i = 1; i < points.length; i++) {
      final prev = points[i - 1];
      final curr = points[i];

      final seg = _distance.as(
        LengthUnit.Meter,
        prev.latLng,
        curr.latLng,
      );

      if (!seg.isFinite || seg <= 0) continue;

      final dt = prev.time != null && curr.time != null
          ? curr.time!.difference(prev.time!).inSeconds.abs()
          : null;

      if (dt == null || dt == 0) {
        if (seg > 500) continue;
        dist += seg;
        continue;
      }

      if (seg < 0.3) continue;

      final speedMps = seg / dt;
      if (speedMps > 30) continue;

      dist += seg;
    }

    return dist > 0 ? dist : null;
  }

  /// Fallback stile app commerciali:
  /// - rimozione quote impossibili;
  /// - smoothing mediano;
  /// - rimozione spike verticali;
  /// - somma delle salite valle-picco sopra soglia.
  ///
  /// Non fa quota finale - quota iniziale, quindi i giri circolari non si annullano.
  static double? _computeElevationLikeCommercialApps(List<_TcxPoint> points) {
    final elevations = points
        .map((p) => p.elevationMeters)
        .whereType<double>()
        .where((e) => e.isFinite && e > -500 && e < 9000)
        .toList();

    if (elevations.length < 2) return null;

    final smoothed = _medianSmooth(elevations, radius: 2);

    const minClimbMeters = 2.0;
    const reverseThresholdMeters = 2.0;
    const maxVerticalJumpMeters = 80.0;

    double gain = 0;
    double valley = smoothed.first;
    double peak = smoothed.first;
    double previous = smoothed.first;

    for (int i = 1; i < smoothed.length; i++) {
      final current = smoothed[i];
      final delta = current - previous;

      if (delta.abs() > maxVerticalJumpMeters) {
        previous = current;
        valley = current;
        peak = current;
        continue;
      }

      if (current > peak) {
        peak = current;
      }

      if (current < valley) {
        valley = current;
        peak = current;
      }

      if (peak - current >= reverseThresholdMeters) {
        final climb = peak - valley;
        if (climb >= minClimbMeters) {
          gain += climb;
        }

        valley = current;
        peak = current;
      }

      previous = current;
    }

    final finalClimb = peak - valley;
    if (finalClimb >= minClimbMeters) {
      gain += finalClimb;
    }

    return gain > 0 ? gain : null;
  }

  static List<double> _medianSmooth(List<double> values, {required int radius}) {
    if (values.length < 3 || radius <= 0) return List<double>.from(values);

    final result = <double>[];

    for (int i = 0; i < values.length; i++) {
      final start = i - radius < 0 ? 0 : i - radius;
      final end = i + radius >= values.length ? values.length - 1 : i + radius;

      final window = <double>[];
      for (int j = start; j <= end; j++) {
        window.add(values[j]);
      }

      window.sort();
      result.add(window[window.length ~/ 2]);
    }

    return result;
  }

  static Duration? _computeDuration(List<_TcxPoint> points) {
    final times = points.map((p) => p.time).nonNulls.toList();
    if (times.length < 2) return null;

    times.sort();
    final duration = times.last.difference(times.first);
    return duration.isNegative ? null : duration;
  }

  static DateTime? _computeStartTime(List<_TcxPoint> points) {
    final times = points.map((p) => p.time).nonNulls.toList();
    if (times.isEmpty) return null;

    times.sort();
    return times.first;
  }

  static double? _minElevation(List<_TcxPoint> points) {
    final values = points
        .map((p) => p.elevationMeters)
        .whereType<double>()
        .where((e) => e.isFinite)
        .toList();

    if (values.isEmpty) return null;
    values.sort();
    return values.first;
  }

  static double? _maxElevation(List<_TcxPoint> points) {
    final values = points
        .map((p) => p.elevationMeters)
        .whereType<double>()
        .where((e) => e.isFinite)
        .toList();

    if (values.isEmpty) return null;
    values.sort();
    return values.last;
  }

  static int _compareByTimeKeepingNullsLast(_TcxPoint a, _TcxPoint b) {
    final at = a.time;
    final bt = b.time;

    if (at == null && bt == null) return 0;
    if (at == null) return 1;
    if (bt == null) return -1;

    return at.compareTo(bt);
  }

  static String? _normalizeSport(String? raw) {
    final value = raw?.trim().toLowerCase();
    if (value == null || value.isEmpty) return null;

    switch (value) {
      case 'running':
        return 'Corsa';
      case 'biking':
      case 'cycling':
        return 'Bici';
      case 'walking':
        return 'Camminata';
      case 'hiking':
        return 'Trekking';
      case 'swimming':
        return 'Nuoto';
      default:
        return raw?.trim();
    }
  }
}

class _TcxPoint {
  final LatLng latLng;
  final double? elevationMeters;
  final DateTime? time;
  final double? distanceMeters;

  const _TcxPoint({
    required this.latLng,
    this.elevationMeters,
    this.time,
    this.distanceMeters,
  });
}

extension _FirstOrNull<E> on Iterable<E> {
  E? get firstOrNull {
    final it = iterator;
    if (!it.moveNext()) return null;
    return it.current;
  }

  E? firstWhereOrNull(bool Function(E value) test) {
    for (final value in this) {
      if (test(value)) return value;
    }
    return null;
  }
}
