import 'dart:convert';
import 'dart:async';
import 'dart:math';

import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';

typedef ElevationDataSource =
    Future<List<double?>> Function(List<LatLng> points);

class LineOfSightSample {
  final double distanceMeters;
  final double terrainMeters;
  final double lineHeightMeters;
  final double refractedHeightMeters;
  final double clearanceMeters;

  const LineOfSightSample({
    required this.distanceMeters,
    required this.terrainMeters,
    required this.lineHeightMeters,
    required this.refractedHeightMeters,
    required this.clearanceMeters,
  });
}

class LineOfSightResult {
  final bool hasData;
  final bool isClear;
  final double totalDistanceMeters;
  final double maxObstructionMeters;
  final double? firstObstructionDistanceMeters;
  final List<LineOfSightSample> samples;
  final String? errorMessage;
  final double usedKFactor;
  final double? frequencyMHz;

  const LineOfSightResult({
    required this.hasData,
    required this.isClear,
    required this.totalDistanceMeters,
    required this.maxObstructionMeters,
    required this.firstObstructionDistanceMeters,
    required this.samples,
    required this.usedKFactor,
    this.frequencyMHz,
    this.errorMessage,
  });

  const LineOfSightResult.error({
    required this.totalDistanceMeters,
    required this.errorMessage,
    this.usedKFactor = 4.0 / 3.0,
    this.frequencyMHz,
  }) : hasData = false,
       isClear = false,
       maxObstructionMeters = 0,
       firstObstructionDistanceMeters = null,
       samples = const [];
}

class LineOfSightPathSegment {
  final int index;
  final LatLng start;
  final LatLng end;
  final LineOfSightResult result;

  const LineOfSightPathSegment({
    required this.index,
    required this.start,
    required this.end,
    required this.result,
  });
}

class LineOfSightPathResult {
  final List<LineOfSightPathSegment> segments;
  final int clearSegments;
  final int blockedSegments;
  final int unknownSegments;

  const LineOfSightPathResult({
    required this.segments,
    required this.clearSegments,
    required this.blockedSegments,
    required this.unknownSegments,
  });
}

class LineOfSightService {
  static const String errorElevationUnavailable =
      'los_error_elevation_unavailable';
  static const String errorInvalidInput = 'los_error_invalid_input';

  static const double _earthRadiusMeters = 6371000.0;
  static const Distance _distance = Distance();
  static const Duration _cacheTtl = Duration(hours: 24);
  static const int _maxFetchAttempts = 4; // initial try + 3 retries
  static const Duration _initialBackoff = Duration(milliseconds: 300);
  static const double _baselineFrequencyMHz = 915.0;
  static const double _baselineKFactor = 4.0 / 3.0;

  static double get baselineFrequencyMHz => _baselineFrequencyMHz;
  static double get baselineKFactor => _baselineKFactor;

  final http.Client _httpClient;
  final bool _ownsHttpClient;
  final ElevationDataSource? _elevationDataSource;
  final Map<String, _CachedElevation> _elevationCache = {};

  LineOfSightService({
    http.Client? httpClient,
    ElevationDataSource? elevationDataSource,
  }) : _httpClient = httpClient ?? http.Client(),
       _ownsHttpClient = httpClient == null,
       _elevationDataSource = elevationDataSource;

  Future<LineOfSightPathResult> analyzePath(
    List<LatLng> points, {
    double startAntennaHeightMeters = 1.5,
    double endAntennaHeightMeters = 1.5,
    double? frequencyMHz,
    double obstructionToleranceMeters = 0.0,
  }) async {
    if (points.length < 2) {
      return const LineOfSightPathResult(
        segments: [],
        clearSegments: 0,
        blockedSegments: 0,
        unknownSegments: 0,
      );
    }

    final segments = <LineOfSightPathSegment>[];
    var clearSegments = 0;
    var blockedSegments = 0;
    var unknownSegments = 0;

    final kFactor = _kFactorForFrequency(frequencyMHz);
    for (int i = 0; i < points.length - 1; i++) {
      final result = await analyzeLink(
        points[i],
        points[i + 1],
        startAntennaHeightMeters: startAntennaHeightMeters,
        endAntennaHeightMeters: endAntennaHeightMeters,
        kFactor: kFactor,
        frequencyMHz: frequencyMHz,
        obstructionToleranceMeters: obstructionToleranceMeters,
      );
      segments.add(
        LineOfSightPathSegment(
          index: i,
          start: points[i],
          end: points[i + 1],
          result: result,
        ),
      );

      if (!result.hasData) {
        unknownSegments++;
      } else if (result.isClear) {
        clearSegments++;
      } else {
        blockedSegments++;
      }
    }

    return LineOfSightPathResult(
      segments: segments,
      clearSegments: clearSegments,
      blockedSegments: blockedSegments,
      unknownSegments: unknownSegments,
    );
  }

  Future<LineOfSightResult> analyzeLink(
    LatLng start,
    LatLng end, {
    double startAntennaHeightMeters = 1.5,
    double endAntennaHeightMeters = 1.5,
    required double kFactor,
    double? frequencyMHz,
    double obstructionToleranceMeters = 0.0,
  }) async {
    final totalDistanceMeters = _distance.as(LengthUnit.Meter, start, end);
    if (totalDistanceMeters <= 1) {
      return LineOfSightResult(
        hasData: true,
        isClear: true,
        totalDistanceMeters: totalDistanceMeters,
        maxObstructionMeters: 0,
        firstObstructionDistanceMeters: null,
        samples: const [],
        usedKFactor: kFactor,
        frequencyMHz: frequencyMHz,
      );
    }

    final samplePoints = _buildSamplePoints(start, end, totalDistanceMeters);
    final elevations = await _getElevations(samplePoints);

    if (elevations.any((e) => e == null)) {
      return LineOfSightResult.error(
        totalDistanceMeters: totalDistanceMeters,
        errorMessage: errorElevationUnavailable,
        usedKFactor: kFactor,
        frequencyMHz: frequencyMHz,
      );
    }

    return computeFromElevations(
      points: samplePoints,
      elevations: elevations.cast<double>(),
      startAntennaHeightMeters: startAntennaHeightMeters,
      endAntennaHeightMeters: endAntennaHeightMeters,
      kFactor: kFactor,
      frequencyMHz: frequencyMHz,
      obstructionToleranceMeters: obstructionToleranceMeters,
    );
  }

  static LineOfSightResult computeFromElevations({
    required List<LatLng> points,
    required List<double> elevations,
    double startAntennaHeightMeters = 1.5,
    double endAntennaHeightMeters = 1.5,
    required double kFactor,
    double? frequencyMHz,
    double obstructionToleranceMeters = 0.0,
  }) {
    if (points.length < 2 || elevations.length != points.length) {
      return LineOfSightResult.error(
        totalDistanceMeters: 0,
        errorMessage: errorInvalidInput,
        usedKFactor: kFactor,
        frequencyMHz: frequencyMHz,
      );
    }

    final totalDistanceMeters = _distance.as(
      LengthUnit.Meter,
      points.first,
      points.last,
    );
    final effectiveEarthRadius = _earthRadiusMeters * kFactor;
    final startLineHeight = elevations.first + startAntennaHeightMeters;
    final endLineHeight = elevations.last + endAntennaHeightMeters;

    var maxObstructionMeters = 0.0;
    double? firstObstructionDistanceMeters;
    final samples = <LineOfSightSample>[];
    var isClear = true;

    for (int i = 0; i < points.length; i++) {
      final fraction = points.length == 1 ? 0.0 : i / (points.length - 1);
      final distanceFromStart = totalDistanceMeters * fraction;
      final lineHeight =
          startLineHeight + (endLineHeight - startLineHeight) * fraction;

      final earthBulge =
          (distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
          (2 * effectiveEarthRadius);
      final terrainHeight = elevations[i] + earthBulge;
      final clearance = lineHeight - terrainHeight;
      final unrefBulge =
          (distanceFromStart * (totalDistanceMeters - distanceFromStart)) /
          (2 * _earthRadiusMeters);
      final refractedHeight = lineHeight + (unrefBulge - earthBulge);

      if (clearance < -obstructionToleranceMeters) {
        isClear = false;
        final obstruction = -clearance;
        if (obstruction > maxObstructionMeters) {
          maxObstructionMeters = obstruction;
        }
        firstObstructionDistanceMeters ??= distanceFromStart;
      }

      samples.add(
        LineOfSightSample(
          distanceMeters: distanceFromStart,
          terrainMeters: terrainHeight,
          lineHeightMeters: lineHeight,
          refractedHeightMeters: refractedHeight,
          clearanceMeters: clearance,
        ),
      );
    }

    return LineOfSightResult(
      hasData: true,
      isClear: isClear,
      totalDistanceMeters: totalDistanceMeters,
      maxObstructionMeters: maxObstructionMeters,
      firstObstructionDistanceMeters: firstObstructionDistanceMeters,
      samples: samples,
      usedKFactor: kFactor,
      frequencyMHz: frequencyMHz,
    );
  }

  static double _kFactorForFrequency(double? frequencyMHz) {
    if (frequencyMHz == null) return _baselineKFactor;
    final delta =
        (frequencyMHz - _baselineFrequencyMHz) / _baselineFrequencyMHz;
    final adjustment = delta * 0.15;
    final scaled = _baselineKFactor * (1 + adjustment);
    return scaled.clamp(1.1, 1.6).toDouble();
  }

  List<LatLng> _buildSamplePoints(
    LatLng start,
    LatLng end,
    double distanceMeters,
  ) {
    final sampleCount = distanceMeters < 2000
        ? 21
        : distanceMeters < 10000
        ? 41
        : 81;

    final points = <LatLng>[];
    for (int i = 0; i < sampleCount; i++) {
      final t = i / (sampleCount - 1);
      points.add(
        LatLng(
          start.latitude + (end.latitude - start.latitude) * t,
          start.longitude + (end.longitude - start.longitude) * t,
        ),
      );
    }
    return points;
  }

  Future<List<double?>> _getElevations(List<LatLng> points) async {
    final dataSource = _elevationDataSource;
    if (dataSource != null) {
      return dataSource(points);
    }

    final uncached = <int, LatLng>{};
    final values = List<double?>.filled(points.length, null);
    for (int i = 0; i < points.length; i++) {
      final key = _cacheKey(points[i]);
      final cached = _readCachedValue(key);
      if (cached != null) {
        values[i] = cached;
      } else {
        uncached[i] = points[i];
      }
    }

    if (uncached.isEmpty) return values;

    final latCsv = uncached.values
        .map((p) => p.latitude.toStringAsFixed(6))
        .join(',');
    final lonCsv = uncached.values
        .map((p) => p.longitude.toStringAsFixed(6))
        .join(',');

    final uri = Uri.parse(
      'https://api.open-meteo.com/v1/elevation?latitude=$latCsv&longitude=$lonCsv',
    );

    final response = await _getWithBackoff(uri);
    if (response.statusCode != 200) {
      return values;
    }

    final decoded = jsonDecode(response.body);
    if (decoded is! Map<String, dynamic>) {
      return values;
    }
    final elevations = decoded['elevation'];
    if (elevations is! List) {
      return values;
    }

    final indices = uncached.keys.toList();
    for (int i = 0; i < min(indices.length, elevations.length); i++) {
      final value = elevations[i];
      if (value is! num) continue;
      final index = indices[i];
      final elevation = value.toDouble();
      values[index] = elevation;
      _elevationCache[_cacheKey(points[index])] = _CachedElevation(
        value: elevation,
        expiresAt: DateTime.now().add(_cacheTtl),
      );
    }
    return values;
  }

  Future<http.Response> _getWithBackoff(Uri uri) async {
    var attempt = 0;
    Duration backoff = _initialBackoff;

    while (true) {
      attempt++;
      try {
        final response = await _httpClient.get(uri);
        if (!_shouldRetryStatus(response.statusCode) ||
            attempt >= _maxFetchAttempts) {
          return response;
        }
      } catch (_) {
        if (attempt >= _maxFetchAttempts) rethrow;
      }

      await Future.delayed(backoff);
      backoff *= 2;
    }
  }

  bool _shouldRetryStatus(int statusCode) {
    return statusCode == 429 || statusCode >= 500;
  }

  double? _readCachedValue(String key) {
    final cached = _elevationCache[key];
    if (cached == null) return null;
    if (DateTime.now().isAfter(cached.expiresAt)) {
      _elevationCache.remove(key);
      return null;
    }
    return cached.value;
  }

  String _cacheKey(LatLng point) {
    return '${point.latitude.toStringAsFixed(5)},${point.longitude.toStringAsFixed(5)}';
  }

  void dispose() {
    if (_ownsHttpClient) {
      _httpClient.close();
    }
  }
}

class _CachedElevation {
  final double value;
  final DateTime expiresAt;

  const _CachedElevation({required this.value, required this.expiresAt});
}
