import 'dart:convert';

import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import '../test_helpers/backend_client_fixtures.dart';

/// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the
/// per-backend protocol that used to be hand-rolled (3×) inside the player's
/// live methods: tune → lazy stream URL, time-shift offsets reusing the
/// transcode session, the Tunarr duration-grow guard on heartbeats, and
/// recover-with-degradation.
void main() {
  late AppDatabase db;

  setUp(() async {
    db = AppDatabase.forTesting(NativeDatabase.memory());
    PlexApiCache.initialize(db);
  });

  tearDown(() async {
    await db.close();
  });

  http.Response jsonResponse(Map<String, dynamic> body) =>
      http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});

  group('Plex live playback session', () {
    Map<String, dynamic> tuneResponse() => {
      'MediaContainer': {
        'MediaSubscription': [
          {
            'MediaGrabOperation': [
              {
                'Metadata': {
                  'ratingKey': 'prog-1',
                  'key': '/livetv/sessions/session-abc',
                  'type': 'clip',
                  'duration': 1800000,
                  'Media': [
                    {'beginsAt': '1700000000'},
                  ],
                },
              },
            ],
          },
        ],
        'TranscodeSession': [
          {'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '120'},
        ],
      },
    };

    PlexClient makeClient(
      Future<http.Response> Function(http.Request request) handler, {
      List<String>? prioritizedEndpoints,
    }) => testPlexClient(
      config: PlexConfig(
        baseUrl: 'https://plex.example.com',
        token: 'tok',
        clientIdentifier: 'client',
        product: 'Plezy',
        version: '1',
        machineIdentifier: 'machine-1',
      ),
      serverId: ServerId('machine-1'),
      httpClient: MockClient(handler),
      prioritizedEndpoints: prioritizedEndpoints,
    );

    test('startPlayback without a dvrKey returns null (tune requires a DVR)', () async {
      final client = makeClient((request) async => fail('no request expected'));
      addTearDown(client.close);

      expect(await client.liveTv.startPlayback('ch-1'), isNull);
    });

    test('startPlayback tunes and exposes program + capture buffer; URL is built lazily', () async {
      final requests = <String>[];
      final client = makeClient((request) async {
        requests.add(request.url.path);
        if (request.url.path.endsWith('/tune')) {
          return jsonResponse(tuneResponse());
        }
        return jsonResponse(const {});
      });
      addTearDown(client.close);

      final session = await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1');

      expect(session, isNotNull);
      expect(session!.program.id, 'prog-1');
      expect(session.program.durationMs, 1800000);
      expect(session.program.beginsAt, 1700000000);
      expect(session.captureBuffer, isNotNull);
      expect(session.canTimeShift, isTrue);
      // Tune only — no transcode decision until the caller asks for a URL
      // (a watch-from-start dialog sits between the two).
      expect(requests, ['/livetv/dvrs/dvr-1/channels/ch-1/tune']);
    });

    test('streamUrlAt builds live-edge and offset HLS URLs against one transcode session', () async {
      final client = makeClient((request) async {
        if (request.url.path.endsWith('/tune')) {
          return jsonResponse(tuneResponse());
        }
        if (request.url.path == '/video/:/transcode/universal/decision') {
          return http.Response('ok', 200);
        }
        return jsonResponse(const {});
      });
      addTearDown(client.close);

      final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;

      final liveEdge = await session.streamUrlAt();
      final shifted = await session.streamUrlAt(offsetSeconds: 90);

      expect(liveEdge, isNotNull);
      final liveEdgeUri = Uri.parse(liveEdge!);
      expect(liveEdgeUri.path, '/video/:/transcode/universal/start.m3u8');
      expect(liveEdgeUri.queryParameters['path'], '/livetv/sessions/session-abc');
      expect(liveEdgeUri.queryParameters['protocol'], 'hls');
      expect(liveEdgeUri.queryParameters['X-Plex-Incomplete-Segments'], '1');
      expect(liveEdgeUri.queryParameters.containsKey('X-Plex-Chunked'), isFalse);
      expect(liveEdgeUri.queryParameters['X-Plex-Client-Profile-Extra'], contains('protocol=hls&container=mpegts'));
      expect(liveEdgeUri.queryParameters['subtitles'], 'none');
      expect(liveEdgeUri.queryParameters.containsKey('subtitleStreamID'), isFalse);
      expect(liveEdgeUri.queryParameters.containsKey('advancedSubtitles'), isFalse);
      expect(liveEdgeUri.queryParameters['X-Plex-Token'], 'tok');
      expect(liveEdgeUri.queryParameters.containsKey('offset'), isFalse);

      final shiftedUri = Uri.parse(shifted!);
      expect(shiftedUri.queryParameters['offset'], '90');
      // Same transcode session across rebuilds so the server reuses its
      // capture buffer.
      expect(shiftedUri.queryParameters['session'], liveEdgeUri.queryParameters['session']);
    });

    test('reportTimeline targets the tuned program and grows duration to the position', () async {
      Map<String, String>? timelineQuery;
      final client = makeClient((request) async {
        if (request.url.path.endsWith('/tune')) {
          return jsonResponse(tuneResponse());
        }
        if (request.url.path == '/:/timeline') {
          timelineQuery = request.url.queryParameters;
          return jsonResponse({
            'MediaContainer': {
              'TranscodeSession': [
                {'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '300'},
              ],
            },
          });
        }
        return jsonResponse(const {});
      });
      addTearDown(client.close);

      final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
      // Position past the program duration — Plex 400s when time > duration
      // (Tunarr-style short synthetic programs), so duration must grow.
      final updated = await session.reportTimeline(state: 'playing', positionMs: 2000000, durationMs: 1800000);

      expect(timelineQuery!['ratingKey'], 'prog-1');
      expect(timelineQuery!['key'], '/livetv/sessions/session-abc');
      expect(timelineQuery!['state'], 'playing');
      expect(timelineQuery!['time'], '2000000');
      expect(timelineQuery!['duration'], '2000000');
      expect(updated, isNotNull);
      expect(updated!.seekableDurationSeconds, 300);
    });

    test('reportTimeline does not fail over because it keeps the active live session alive', () async {
      final requests = <Uri>[];
      final client = makeClient((request) async {
        requests.add(request.url);
        if (request.url.path.endsWith('/tune')) {
          return jsonResponse(tuneResponse());
        }
        if (request.url.path == '/:/timeline') {
          throw http.ClientException('temporary timeline DNS failure', request.url);
        }
        return jsonResponse(const {});
      }, prioritizedEndpoints: const ['https://plex.example.com', 'https://fallback.example.com']);
      addTearDown(client.close);

      final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;

      await expectLater(
        session.reportTimeline(state: 'playing', positionMs: 10000, durationMs: 1800000),
        throwsA(isA<MediaServerHttpException>()),
      );
      expect(requests.where((uri) => uri.path == '/:/timeline'), hasLength(1));
      expect(client.config.baseUrl, 'https://plex.example.com');
    });

    test('recover re-tunes and the fresh session builds degraded URLs', () async {
      var tunes = 0;
      final client = makeClient((request) async {
        if (request.url.path.endsWith('/tune')) {
          tunes++;
          return jsonResponse(tuneResponse());
        }
        if (request.url.path == '/video/:/transcode/universal/decision') {
          return http.Response('ok', 200);
        }
        return jsonResponse(const {});
      });
      addTearDown(client.close);

      final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
      final recovered = await session.recover(directStream: false, directStreamAudio: false);

      expect(tunes, 2);
      final url = await recovered!.streamUrlAt();
      final uri = Uri.parse(url!);
      expect(uri.queryParameters['directStream'], '0');
      expect(uri.queryParameters['directStreamAudio'], '0');
    });
  });

  group('Jellyfin live playback session', () {
    JellyfinConnection conn() => JellyfinConnection(
      id: 'srv-1/user-1',
      baseUrl: 'https://jf.example.com',
      serverName: 'Home',
      serverMachineId: 'srv-1',
      userId: 'user-1',
      userName: 'edde',
      accessToken: 'tok-abc',
      deviceId: 'dev-xyz',
      createdAt: DateTime.fromMillisecondsSinceEpoch(0),
    );

    test('startPlayback negotiates one HLS URL; no time-shift; recover reuses it', () async {
      final client = JellyfinClient.forTesting(
        connection: conn(),
        httpClient: MockClient((request) async {
          if (request.url.path.contains('PlaybackInfo')) {
            return jsonResponse({
              'PlaySessionId': 'play-1',
              'MediaSources': [
                {
                  'Id': 'source-1',
                  'Container': 'ts',
                  'LiveStreamId': 'live-1',
                  'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1',
                },
              ],
            });
          }
          return jsonResponse(const {});
        }),
      );
      addTearDown(client.close);

      final session = await client.liveTv.startPlayback('channel-1');

      expect(session, isNotNull);
      expect(session!.program.id, isNull);
      expect(session.captureBuffer, isNull);
      expect(session.canTimeShift, isFalse);
      expect(session.backgroundPolicy, LiveTvBackgroundPolicy.stopAndExit);

      final url = await session.streamUrlAt();
      expect(url, isNotNull);
      expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8');
      expect(Uri.parse(url).queryParameters['PlaySessionId'], 'play-1');

      // Time-shift unsupported — an offset request must not silently play live.
      expect(await session.streamUrlAt(offsetSeconds: 60), isNull);

      // Recovery re-opens the negotiated HLS URL.
      expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
    });

    test('startPlayback propagates status and cancellation failures', () async {
      final handlers = <(String, Future<http.Response> Function(http.Request))>[
        ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
        ('500', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})),
        ('cancelled', (request) async => throw http.RequestAbortedException(request.url)),
      ];

      for (final (name, handler) in handlers) {
        final client = JellyfinClient.forTesting(connection: conn(), httpClient: MockClient(handler));
        addTearDown(client.close);
        await expectLater(
          client.liveTv.startPlayback('channel-1'),
          throwsA(isA<MediaServerHttpException>()),
          reason: name,
        );
      }
    });

    test('malformed successful playback data throws distinctly', () async {
      final missingSources = JellyfinClient.forTesting(
        connection: conn(),
        httpClient: MockClient((_) async => jsonResponse({'PlaySessionId': 'play-1'})),
      );
      addTearDown(missingSources.close);
      await expectLater(
        missingSources.liveTv.startPlayback('channel-1'),
        throwsA(
          isA<MediaServerHttpException>()
              .having((error) => error.statusCode, 'statusCode', 200)
              .having((error) => error.responseData, 'responseData', isNull),
        ),
      );

      final malformedSource = JellyfinClient.forTesting(
        connection: conn(),
        httpClient: MockClient(
          (_) async => jsonResponse({
            'MediaSources': ['invalid'],
          }),
        ),
      );
      addTearDown(malformedSource.close);
      await expectLater(
        malformedSource.liveTv.startPlayback('channel-1'),
        throwsA(
          isA<PlaybackException>().having((error) => error.reason, 'reason', PlaybackFailureReason.invalidPlaybackData),
        ),
      );
    });

    test('only a valid empty source list returns no live stream', () async {
      final client = JellyfinClient.forTesting(
        connection: conn(),
        httpClient: MockClient((_) async => jsonResponse({'MediaSources': []})),
      );
      addTearDown(client.close);

      expect(await client.liveTv.startPlayback('channel-1'), isNull);
    });
  });
}
