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

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart' show protected;
import 'package:flutter/services.dart';

import '../../media/media_display_criteria.dart';
import '../../utils/app_logger.dart';
import '../../utils/track_label_builder.dart';
import '../font_loader.dart';
import '../models.dart';
import 'player.dart';
import 'player_state.dart';
import 'player_stream_controllers.dart';
import 'player_streams.dart';

/// Abstract base class for player implementations.
///
/// This class contains shared logic for both [PlayerAndroid] (ExoPlayer)
/// and [PlayerNative] (MPV) implementations, including:
/// - State management
/// - Stream controller setup
/// - Event handling infrastructure
/// - Property change handlers
/// - Track parsing and selection
/// - Common lifecycle methods
abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
  PlayerState _state = const PlayerState();

  @override
  PlayerState get state => _state;

  late final PlayerStreams _streams;

  @override
  PlayerStreams get streams => _streams;

  @override
  int? get textureId => null;

  StreamSubscription? _eventSubscription;
  StreamSubscription? _logSubscription;
  bool _disposed = false;
  final _throttleSw = Stopwatch()..start();
  int _lastEmitMs = 0;
  int _lastCacheStateMs = 0;
  int _positionMs = 0;
  int _nextPropId = 0;
  final Map<int, String> _propIdToName = {};

  @protected
  bool initialized = false;

  @override
  bool get disposed => _disposed;

  MethodChannel get methodChannel;

  EventChannel get eventChannel;

  String get logPrefix;

  PlayerBase() {
    _streams = createStreams();
    _setupEventListener();
    _logSubscription = logController.stream.listen(_forwardToAppLogger);
  }

  void _forwardToAppLogger(PlayerLog log) {
    final message = '[$logPrefix:${log.prefix}] ${log.text}'.trimRight();
    switch (log.level) {
      case PlayerLogLevel.fatal:
      case PlayerLogLevel.error:
        appLogger.e(message);
      case PlayerLogLevel.warn:
        appLogger.w(message);
      case PlayerLogLevel.info:
      case PlayerLogLevel.verbose:
        appLogger.i(message);
      case PlayerLogLevel.debug:
      case PlayerLogLevel.trace:
        appLogger.d(message);
      case PlayerLogLevel.none:
        break;
    }
  }

  void _setupEventListener() {
    _eventSubscription = eventChannel.receiveBroadcastStream().listen(
      _handleEvent,
      onError: (error) {
        if (_disposed) return;
        errorController.add(PlayerError(error.toString()));
      },
    );
  }

  @protected
  Future<void> observeProperty(String name, String format) async {
    final propId = _nextPropId++;
    _propIdToName[propId] = name;
    await invoke('observeProperty', {'name': name, 'format': format, 'id': propId});
  }

  void _handleEvent(dynamic event) {
    if (_disposed) return;
    if (event is List && event.length == 2) {
      final name = _propIdToName[event.first as int];
      if (name != null) {
        handlePropertyChange(name, event[1]);
      }
    } else if (event is Map) {
      final type = event['type'] as String?;
      final name = event['name'] as String?;
      if (type == 'event' && name != null) {
        handlePlayerEvent(name, event['data'] as Map?);
      }
    }
  }

  void handlePropertyChange(String name, dynamic value) {
    if (_disposed) return;
    switch (name) {
      case 'pause':
        final playing = value == false;
        _state = _state.copyWith(playing: playing);
        playingController.add(playing);
        break;

      case 'eof-reached':
        final completed = value == true;
        _state = _state.copyWith(completed: completed);
        completedController.add(completed);
        break;

      case 'paused-for-cache':
        final buffering = value == true;
        _state = _state.copyWith(buffering: buffering);
        bufferingController.add(buffering);
        break;

      case 'time-pos':
        if (value is num) {
          _positionMs = (value * 1000).round();
          // Only allocate Duration + copyWith + emit at ~4Hz (250ms).
          // Raw int is stored every tick so synchronous reads via _positionMs stay current.
          final nowMs = _throttleSw.elapsedMilliseconds;
          if (nowMs - _lastEmitMs >= 250) {
            _lastEmitMs = nowMs;
            final pos = Duration(milliseconds: _positionMs);
            _state = _state.copyWith(position: pos);
            positionController.add(pos);
          }
        }
        break;

      case 'duration':
        if (value is num) {
          final duration = Duration(milliseconds: (value * 1000).toInt());
          _state = _state.copyWith(duration: duration);
          durationController.add(duration);
        }
        break;

      case 'seekable':
        if (value is bool) {
          setSeekable(value);
        }
        break;

      case 'demuxer-cache-time':
        if (value is num) {
          final nowMs = _throttleSw.elapsedMilliseconds;
          if (nowMs - _lastCacheStateMs < 250) break;
          _lastCacheStateMs = nowMs;
          final buffer = Duration(milliseconds: (value * 1000).toInt());
          _state = _state.copyWith(buffer: buffer);
          bufferController.add(buffer);
          // Synthesize a single range for players without demuxer-cache-state (ExoPlayer).
          // ExoPlayer only buffers ahead of the current position, so use position as start.
          final ranges = [BufferRange(start: _state.position, end: buffer)];
          _state = _state.copyWith(bufferRanges: ranges);
          bufferRangesController.add(ranges);
        }
        break;

      case 'demuxer-cache-state':
        _handleDemuxerCacheState(value);
        break;

      case 'volume':
        if (value is num) {
          setVolumeState(value.toDouble());
        }
        break;

      case 'speed':
        if (value is num) {
          final rate = value.toDouble();
          _state = _state.copyWith(rate: rate);
          rateController.add(rate);
        }
        break;

      case 'track-list':
        List? trackList;
        if (value is List) {
          trackList = value;
        } else if (value is String && value.isNotEmpty) {
          try {
            final parsed = jsonDecode(value);
            if (parsed is List) trackList = parsed;
          } catch (e) {
            appLogger.d('Player: track-list parse failed', error: e);
          }
        }
        if (trackList != null) {
          final result = parseTrackList(trackList);
          _state = _state.copyWith(tracks: result.tracks);
          tracksController.add(result.tracks);
          // Derive selection from mpv's "selected" field in the track data.
          // This is the source of truth and handles cases where aid/sid
          // values don't match track IDs (e.g. "auto", "0", "no").
          if (result.selectedAudioId != null) {
            updateSelectedAudioTrack(result.selectedAudioId);
          }
          if (result.selectedSubtitleId != null) {
            updateSelectedSubtitleTrack(result.selectedSubtitleId);
          }
        }
        break;

      case 'aid':
        updateSelectedAudioTrack(value);
        break;

      case 'sid':
        updateSelectedSubtitleTrack(value);
        break;

      case 'secondary-sid':
        updateSelectedSecondarySubtitleTrack(value);
        break;

      case 'audio-device-list':
        List? deviceList;
        if (value is List) {
          deviceList = value;
        } else if (value is String && value.isNotEmpty) {
          try {
            final parsed = jsonDecode(value);
            if (parsed is List) deviceList = parsed;
          } catch (e) {
            appLogger.d('Player: device-list parse failed', error: e);
          }
        }
        if (deviceList != null) {
          final devices = deviceList
              .whereType<Map>()
              .map((d) => AudioDevice(name: d['name'] as String? ?? '', description: d['description'] as String? ?? ''))
              .toList();
          _state = _state.copyWith(audioDevices: devices);
          audioDevicesController.add(devices);
        }
        break;

      case 'audio-device':
        if (value is String && value.isNotEmpty) {
          final device = _state.audioDevices.firstWhereOrNull((d) => d.name == value) ?? AudioDevice(name: value);
          _state = _state.copyWith(audioDevice: device);
          audioDeviceController.add(device);
        }
        break;
    }
  }

  /// Parse demuxer-cache-state property to extract seekable ranges and buffer end.
  void _handleDemuxerCacheState(dynamic value) {
    Map? cacheState;
    if (value is Map) {
      cacheState = value;
    } else if (value is String && value.isNotEmpty) {
      // Throttle JSON parsing to avoid ANR on low-end devices
      final nowMs = _throttleSw.elapsedMilliseconds;
      if (nowMs - _lastCacheStateMs < 250) return;
      _lastCacheStateMs = nowMs;
      try {
        final parsed = jsonDecode(value);
        if (parsed is Map) cacheState = parsed;
      } catch (_) {}
    }
    if (cacheState == null) return;

    // Extract cache-end for the single buffer duration (replaces demuxer-cache-time)
    final cacheEnd = cacheState['cache-end'] as num?;
    if (cacheEnd != null) {
      final buffer = Duration(milliseconds: (cacheEnd * 1000).toInt());
      _state = _state.copyWith(buffer: buffer);
      bufferController.add(buffer);
    }

    // Extract seekable-ranges array
    final seekableRanges = cacheState['seekable-ranges'];
    if (seekableRanges is List) {
      final ranges = <BufferRange>[];
      for (final range in seekableRanges) {
        if (range is Map) {
          final start = range['start'] as num?;
          final end = range['end'] as num?;
          if (start != null && end != null) {
            ranges.add(
              BufferRange(
                start: Duration(milliseconds: (start * 1000).toInt()),
                end: Duration(milliseconds: (end * 1000).toInt()),
              ),
            );
          }
        }
      }
      _state = _state.copyWith(bufferRanges: ranges);
      bufferRangesController.add(ranges);
    }
  }

  void handlePlayerEvent(String name, Map? data) {
    if (_disposed) return;
    switch (name) {
      case 'end-file':
        setSeekable(false);
        final rawReason = data?['reason'];
        final reason = switch (rawReason) {
          0 => 'eof',
          2 => 'stop',
          3 => 'quit',
          4 => 'error',
          5 => 'redirect',
          final String s => s,
          _ => null,
        };
        if (reason == 'eof') {
          _state = _state.copyWith(completed: true);
          completedController.add(true);
        } else if (reason == 'error') {
          errorController.add(
            PlayerError(data?['message'] as String? ?? 'Playback error', cause: data?['cause'] as String?),
          );
        }
        break;

      case 'file-loaded':
        _state = _state.copyWith(completed: false);
        completedController.add(false);
        break;

      case 'playback-restart':
        playbackRestartController.add(null);
        break;

      case 'log-message':
        final prefix = data?['prefix'] as String? ?? '';
        final levelStr = data?['level'] as String? ?? 'info';
        final text = data?['text'] as String? ?? '';
        final level = parseLogLevel(levelStr);
        logController.add(PlayerLog(level: level, prefix: prefix, text: text));
        break;
    }
  }

  PlayerLogLevel parseLogLevel(String level) {
    return switch (level) {
      'fatal' => PlayerLogLevel.fatal,
      'error' => PlayerLogLevel.error,
      'warn' => PlayerLogLevel.warn,
      'info' => PlayerLogLevel.info,
      'v' || 'verbose' => PlayerLogLevel.verbose,
      'debug' => PlayerLogLevel.debug,
      'trace' => PlayerLogLevel.trace,
      _ => PlayerLogLevel.info,
    };
  }

  ({Tracks tracks, String? selectedAudioId, String? selectedSubtitleId}) parseTrackList(List trackList) {
    final audioTracks = <AudioTrack>[];
    final subtitleTracks = <SubtitleTrack>[];
    String? selectedAudioId;
    String? selectedSubtitleId;

    for (final track in trackList) {
      if (track is! Map) continue;

      final type = track['type'] as String?;
      final id = track['id']?.toString() ?? '';
      final selected = track['selected'] as bool? ?? false;

      if (type == 'audio') {
        if (selected) selectedAudioId = id;
        audioTracks.add(
          AudioTrack(
            id: id,
            title: cleanTrackMetadataValue(track['title'] as String?),
            language: cleanTrackMetadataValue(track['lang'] as String?),
            codec: track['codec'] as String?,
            channels: (track['demux-channel-count'] as num?)?.toInt(),
            sampleRate: (track['demux-samplerate'] as num?)?.toInt(),
            isDefault: track['default'] as bool? ?? false,
          ),
        );
      } else if (type == 'sub') {
        if (selected) selectedSubtitleId = id;
        final codec = track['codec'] as String?;
        subtitleTracks.add(
          SubtitleTrack(
            id: id,
            title: cleanSubtitleTitle(track['title'] as String?, codec: codec),
            language: cleanTrackMetadataValue(track['lang'] as String?),
            codec: codec,
            isDefault: track['default'] as bool? ?? false,
            isForced: track['forced'] as bool? ?? false,
            isExternal: track['external'] as bool? ?? false,
            uri: track['external-filename'] as String?,
          ),
        );
      }
    }

    return (
      tracks: Tracks(audio: audioTracks, subtitle: subtitleTracks),
      selectedAudioId: selectedAudioId,
      selectedSubtitleId: selectedSubtitleId,
    );
  }

  void updateSelectedAudioTrack(dynamic trackId) {
    final id = trackId?.toString();
    AudioTrack? selectedTrack;

    if (id != null && id != 'no') {
      selectedTrack = _state.tracks.audio.firstWhereOrNull((t) => t.id == id);
    }

    if (selectedTrack == null) return;
    _state = _state.copyWith(track: _state.track.copyWith(audio: selectedTrack));
    trackController.add(_state.track);
  }

  void updateSelectedSubtitleTrack(dynamic trackId) {
    final id = trackId?.toString();
    final selectedTrack = (id == null || id == 'no')
        ? SubtitleTrack.off
        : _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);

    if (selectedTrack == null) return;
    _state = _state.copyWith(track: _state.track.copyWith(subtitle: selectedTrack));
    trackController.add(_state.track);
  }

  void updateSelectedSecondarySubtitleTrack(dynamic trackId) {
    final id = trackId?.toString();
    SubtitleTrack? selectedTrack;

    if (id == null || id == 'no') {
      selectedTrack = null;
    } else {
      selectedTrack = _state.tracks.subtitle.firstWhereOrNull((t) => t.id == id);
    }

    _state = _state.copyWith(track: _state.track.copyWith(secondarySubtitle: selectedTrack));
    trackController.add(_state.track);
  }

  @protected
  void clearTracks() {
    const empty = Tracks();
    _state = _state.copyWith(tracks: empty, track: const TrackSelection());
    tracksController.add(empty);
  }

  @protected
  void setVolumeState(double volume) {
    if (_state.volume == volume) return;
    _state = _state.copyWith(volume: volume);
    volumeController.add(volume);
  }

  @protected
  void setSeekable(bool seekable) {
    if (_state.seekable == seekable) return;
    _state = _state.copyWith(seekable: seekable);
    seekableController.add(seekable);
  }

  @protected
  void resetPlaybackProgress(Duration position) {
    _positionMs = position.inMilliseconds;
    _state = _state.copyWith(
      completed: false,
      position: position,
      duration: Duration.zero,
      buffer: Duration.zero,
      bufferRanges: const [],
    );
    completedController.add(false);
    positionController.add(position);
    durationController.add(Duration.zero);
    bufferController.add(Duration.zero);
    bufferRangesController.add(const []);
  }

  @protected
  Future<T?> invoke<T>(String method, [dynamic args]) async {
    if (_disposed) return null;
    return methodChannel.invokeMethod<T>(method, args);
  }

  @override
  Future<void> playOrPause() async {
    if (_disposed) return;
    if (_state.playing) {
      await pause();
    } else {
      await play();
    }
  }

  @override
  Future<void> setDisplayCriteria(MediaDisplayCriteria? criteria) async {}

  @override
  Future<bool> setVisible(bool visible, {bool restoreOnWindowVisible = false}) async {
    if (_disposed) return false;
    try {
      await invoke('setVisible', {'visible': visible, 'restoreOnWindowVisible': restoreOnWindowVisible});
      return true;
    } catch (e) {
      errorController.add(PlayerError('Failed to set visibility: $e'));
      return false;
    }
  }

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> updateFrame() async {}

  @override
  Future<bool> setVideoFrameRate(double fps, int durationMs, {int extraDelayMs = 0}) async => false;

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> clearVideoFrameRate() async {}

  @override
  Future<bool> requestAudioFocus() async {
    // Default returns true, overridden by Android
    return true;
  }

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> abandonAudioFocus() async {}

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> setAudioDevice(AudioDevice device) async {}

  @override
  bool get supportsSecondarySubtitles => true;

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> selectSecondarySubtitleTrack(SubtitleTrack track) async {}

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> setAudioPassthrough(bool enabled) async {}

  @override
  // ignore: no-empty-block - base no-op, overridden by platform subclasses
  Future<void> setLogLevel(String level) async {}

  @override
  Future<void> configureSubtitleFonts() async {
    try {
      final fontDir = await SubtitleFontLoader.loadSubtitleFont();
      if (fontDir != null) {
        await setProperty('sub-fonts-dir', fontDir);
        await setProperty('sub-font', SubtitleFontLoader.fontName);
      }
    } catch (e) {
      // Font configuration is not critical - continue without it
      logController.add(
        PlayerLog(prefix: 'fonts', level: PlayerLogLevel.warn, text: 'Failed to configure subtitle fonts: $e'),
      );
    }
  }

  void _setPlaybackPosition(Duration position) {
    _positionMs = position.inMilliseconds;
    _state = _state.copyWith(position: position);
    positionController.add(position);
  }

  /// Run a backend-specific seek call, swallowing the common "not ready" errors
  /// the native channel throws when the engine was torn down mid-seek.
  @protected
  Future<void> runSeek(Duration position, Future<void> Function() seekFn) async {
    if (_disposed) return;

    final previousPosition = Duration(milliseconds: _positionMs);
    _setPlaybackPosition(position);

    void rollbackPosition() {
      // Avoid overwriting a newer native position update if one arrived while
      // the platform seek was in flight.
      if (_positionMs == position.inMilliseconds) {
        _setPlaybackPosition(previousPosition);
      }
    }

    try {
      await seekFn();
    } on PlatformException catch (e) {
      if (e.code == 'COMMAND_FAILED' || e.code == 'NOT_INITIALIZED') {
        rollbackPosition();
        appLogger.w('Seek failed (${e.code}), player not ready');
        return;
      }
      rollbackPosition();
      rethrow;
    } catch (_) {
      rollbackPosition();
      rethrow;
    }
  }

  /// Injects the log + error events that would fire when the server rejects the
  /// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
  /// the in-player debug button to preview the end-to-end detection path
  /// without needing a real misbehaving server.
  void debugSimulateServer500() {
    if (_disposed) return;
    logController.add(
      const PlayerLog(
        level: PlayerLogLevel.warn,
        prefix: 'ffmpeg',
        text: 'https: HTTP error 500 Internal Server Error',
      ),
    );
    errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500));
  }

  @override
  Future<void> dispose() async {
    if (_disposed) return;
    _disposed = true;

    await _eventSubscription?.cancel();
    await _logSubscription?.cancel();
    await methodChannel.invokeMethod('dispose'); // Direct call — already guarded by _disposed check above
    await closeStreamControllers();
  }
}
