import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';

import '../../shared/utils/app_logger.dart';
import 'embedded_subtitle_cues.dart';

const _tag = 'ExternalSubtitleLoader';
const _backgroundParseThresholdBytes = 256 * 1024;


class ExternalSubtitleLoadException implements Exception {
  final String message;
  final Object? cause;

  const ExternalSubtitleLoadException(this.message, [this.cause]);

  @override
  String toString() =>
      'ExternalSubtitleLoadException: $message'
      '${cause != null ? ' ($cause)' : ''}';
}


class ExternalSubtitleLoader {
  final Dio _dio;
  final Map<String, List<TimedSubtitle>> _cacheByUrl = {};
  final Map<String, Future<List<TimedSubtitle>>> _inflightByUrl = {};

  ExternalSubtitleLoader({Dio? dio})
    : _dio =
          dio ??
          Dio(
            BaseOptions(
              connectTimeout: const Duration(seconds: 10),
              receiveTimeout: const Duration(seconds: 20),
              responseType: ResponseType.bytes,
            ),
          );


  Future<List<TimedSubtitle>> load(
    String url, {
    Map<String, String>? headers,
    Duration? receiveTimeout,
  }) {
    final cached = _cacheByUrl[url];
    if (cached != null) return Future.value(cached);

    final inflight = _inflightByUrl[url];
    if (inflight != null) return inflight;

    final future = _download(url, headers, receiveTimeout).whenComplete(() {
      _inflightByUrl.remove(url);
    });
    _inflightByUrl[url] = future;
    return future;
  }

  Future<List<TimedSubtitle>> _download(
    String url,
    Map<String, String>? headers,
    Duration? receiveTimeout,
  ) async {
    List<int> bytes;
    try {
      final response = await _dio.get<List<int>>(
        url,
        options: Options(
          headers: headers,
          responseType: ResponseType.bytes,
          receiveTimeout: receiveTimeout,
        ),
      );
      bytes = response.data ?? const [];
    } on DioException catch (error) {
      AppLogger.warn(
        _tag,
        'download failed url=$url status=${error.response?.statusCode} '
        'type=${error.type}',
      );
      throw ExternalSubtitleLoadException('外挂字幕下载失败', error);
    }
    if (bytes.isEmpty) {
      AppLogger.warn(_tag, 'download returned empty body url=$url');
      throw const ExternalSubtitleLoadException('外挂字幕内容为空');
    }

    final content = _decodeText(bytes);
    final cues = bytes.length >= _backgroundParseThresholdBytes
        ? await compute(parseSubtitleContent, content)
        : parseSubtitleContent(content);
    if (cues.isEmpty) {
      AppLogger.warn(
        _tag,
        'parse produced no cues url=$url bytes=${bytes.length}',
      );
      throw const ExternalSubtitleLoadException('外挂字幕解析结果为空');
    }
    _cacheByUrl[url] = cues;
    AppLogger.debug(_tag, 'loaded url=$url cues=${cues.length}');
    return cues;
  }

  void clear() {
    _cacheByUrl.clear();
    _inflightByUrl.clear();
  }


  static String _decodeText(List<int> bytes) {
    var start = 0;
    if (bytes.length >= 3 &&
        bytes[0] == 0xEF &&
        bytes[1] == 0xBB &&
        bytes[2] == 0xBF) {
      start = 3;
    }
    final body = start == 0 ? bytes : bytes.sublist(start);
    try {
      return utf8.decode(body);
    } on FormatException {
      return latin1.decode(body);
    }
  }


  static List<TimedSubtitle> parseSubtitleContent(String content) {
    final normalized = content.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
    final trimmed = normalized.trimLeft();
    if (trimmed.startsWith('WEBVTT')) {
      return _parseVtt(normalized);
    }
    if (trimmed.startsWith('[Script Info]') ||
        normalized.contains('\nDialogue:') ||
        trimmed.startsWith('Dialogue:')) {
      return _parseAss(normalized);
    }
    return _parseSrt(normalized);
  }


  static final RegExp _srtTimeLine = RegExp(
    r'(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{1,3})',
  );

  static List<TimedSubtitle> _parseSrt(String content) {
    final cues = <TimedSubtitle>[];
    final blocks = content.split(RegExp(r'\n{2,}'));
    for (final block in blocks) {
      final lines = block
          .split('\n')
          .where((line) => line.trim().isNotEmpty)
          .toList();
      if (lines.isEmpty) continue;

      var timeLineIndex = -1;
      RegExpMatch? timeMatch;
      for (var i = 0; i < lines.length; i++) {
        timeMatch = _srtTimeLine.firstMatch(lines[i]);
        if (timeMatch != null) {
          timeLineIndex = i;
          break;
        }
      }
      if (timeMatch == null) continue;

      final textLines = lines
          .sublist(timeLineIndex + 1)
          .map(_stripHtmlTags)
          .where((line) => line.trim().isNotEmpty)
          .toList();
      if (textLines.isEmpty) continue;

      cues.add(
        TimedSubtitle(
          start: _timeFromMatch(timeMatch, 1),
          end: _timeFromMatch(timeMatch, 5),
          text: textLines.join('\n'),
        ),
      );
    }
    return _sortAndValidate(cues);
  }

  static List<TimedSubtitle> _parseVtt(String content) {
    final vttTimeLine = RegExp(
      r'(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})\.(\d{3})',
    );
    final cues = <TimedSubtitle>[];
    final blocks = content.split(RegExp(r'\n{2,}'));
    for (final block in blocks) {
      final lines = block
          .split('\n')
          .where((line) => line.trim().isNotEmpty)
          .toList();
      if (lines.isEmpty) continue;
      final first = lines.first.trim();
      if (first.startsWith('WEBVTT') ||
          first.startsWith('NOTE') ||
          first.startsWith('STYLE') ||
          first.startsWith('REGION')) {
        continue;
      }

      var timeLineIndex = -1;
      RegExpMatch? timeMatch;
      for (var i = 0; i < lines.length; i++) {
        timeMatch = vttTimeLine.firstMatch(lines[i]);
        if (timeMatch != null) {
          timeLineIndex = i;
          break;
        }
      }
      if (timeMatch == null) continue;

      final textLines = lines
          .sublist(timeLineIndex + 1)
          .map(_stripHtmlTags)
          .where((line) => line.trim().isNotEmpty)
          .toList();
      if (textLines.isEmpty) continue;

      cues.add(
        TimedSubtitle(
          start: _vttTimeFromMatch(timeMatch, 1),
          end: _vttTimeFromMatch(timeMatch, 5),
          text: textLines.join('\n'),
        ),
      );
    }
    return _sortAndValidate(cues);
  }


  static final RegExp _assTime = RegExp(r'^(\d+):(\d{2}):(\d{2})\.(\d{2})$');

  static List<TimedSubtitle> _parseAss(String content) {
    final cues = <TimedSubtitle>[];
    var startFieldIndex = 1;
    var endFieldIndex = 2;
    var textFieldIndex = 9;

    for (final rawLine in content.split('\n')) {
      final line = rawLine.trim();
      if (line.startsWith('Format:')) {
        final fields = line
            .substring('Format:'.length)
            .split(',')
            .map((field) => field.trim().toLowerCase())
            .toList();
        final start = fields.indexOf('start');
        final end = fields.indexOf('end');
        final text = fields.indexOf('text');
        if (start >= 0 && end >= 0 && text >= 0) {
          startFieldIndex = start;
          endFieldIndex = end;
          textFieldIndex = text;
        }
        continue;
      }
      if (!line.startsWith('Dialogue:')) continue;

      final payload = line.substring('Dialogue:'.length).trim();
      final parts = payload.split(',');
      if (parts.length <= textFieldIndex) continue;
      final startRaw = parts[startFieldIndex].trim();
      final endRaw = parts[endFieldIndex].trim();
      final textRaw = parts.sublist(textFieldIndex).join(',');

      final start = _parseAssTime(startRaw);
      final end = _parseAssTime(endRaw);
      if (start == null || end == null) continue;

      final text = _cleanAssText(textRaw);
      if (text.isEmpty) continue;

      cues.add(TimedSubtitle(start: start, end: end, text: text));
    }
    return _sortAndValidate(cues);
  }

  static Duration? _parseAssTime(String raw) {
    final match = _assTime.firstMatch(raw);
    if (match == null) return null;
    return Duration(
      hours: int.parse(match.group(1)!),
      minutes: int.parse(match.group(2)!),
      seconds: int.parse(match.group(3)!),
      milliseconds: int.parse(match.group(4)!) * 10,
    );
  }


  static String _cleanAssText(String raw) {
    var text = raw.replaceAll(RegExp(r'\{[^}]*\}'), '');
    text = text
        .replaceAll(r'\N', '\n')
        .replaceAll(r'\n', '\n')
        .replaceAll(r'\h', ' ');
    return text
        .split('\n')
        .map((line) => line.trim())
        .where((line) => line.isNotEmpty)
        .join('\n')
        .trim();
  }

  static String _stripHtmlTags(String line) =>
      line.replaceAll(RegExp(r'</?[^>]+>'), '');

  static Duration _timeFromMatch(RegExpMatch match, int groupOffset) {
    final millisRaw = match.group(groupOffset + 3)!;
    final millis = int.parse(millisRaw.padRight(3, '0'));
    return Duration(
      hours: int.parse(match.group(groupOffset)!),
      minutes: int.parse(match.group(groupOffset + 1)!),
      seconds: int.parse(match.group(groupOffset + 2)!),
      milliseconds: millis,
    );
  }

  static Duration _vttTimeFromMatch(RegExpMatch match, int groupOffset) {
    return Duration(
      hours: int.parse(match.group(groupOffset) ?? '0'),
      minutes: int.parse(match.group(groupOffset + 1)!),
      seconds: int.parse(match.group(groupOffset + 2)!),
      milliseconds: int.parse(match.group(groupOffset + 3)!),
    );
  }


  static List<TimedSubtitle> _sortAndValidate(List<TimedSubtitle> cues) {
    final valid = cues.where((cue) => cue.end >= cue.start).toList()
      ..sort((a, b) => a.start.compareTo(b.start));
    return valid;
  }
}


class SubtitleCueSource {
  final List<TimedSubtitle> _cues;
  final List<Duration> _prefixMaximumEnd;

  SubtitleCueSource(List<TimedSubtitle> sortedCues)
    : _cues = sortedCues,
      _prefixMaximumEnd = _buildPrefixMaximumEnd(sortedCues);

  bool get isEmpty => _cues.isEmpty;


  List<String> textAt(Duration position) {
    final firstPossibleIndex = _firstCueThatMayOverlap(position);
    if (firstPossibleIndex >= _cues.length) return const [];

    final lines = <String>[];
    for (
      var cueIndex = firstPossibleIndex;
      cueIndex < _cues.length;
      cueIndex++
    ) {
      final cue = _cues[cueIndex];
      if (position < cue.start) break;
      if (position <= cue.end) lines.addAll(cue.text.split('\n'));
    }
    return lines;
  }


  int _firstCueThatMayOverlap(Duration position) {
    var lowerBound = 0;
    var upperBound = _prefixMaximumEnd.length;
    while (lowerBound < upperBound) {
      final middleIndex = (lowerBound + upperBound) >> 1;
      if (_prefixMaximumEnd[middleIndex] < position) {
        lowerBound = middleIndex + 1;
      } else {
        upperBound = middleIndex;
      }
    }
    return lowerBound;
  }

  static List<Duration> _buildPrefixMaximumEnd(List<TimedSubtitle> sortedCues) {
    final prefixMaximumEnd = <Duration>[];
    var maximumEnd = Duration.zero;
    for (final cue in sortedCues) {
      if (cue.end > maximumEnd) maximumEnd = cue.end;
      prefixMaximumEnd.add(maximumEnd);
    }
    return prefixMaximumEnd;
  }
}
