// SWE.4 unit tests for AndroidPermissionsService — the Dart-side
// integration layer for the Kotlin `AndroidPermissionRequester`
// (SDD-106) consumed by SRS-209's listen-only fallback / pre-voice_join
// gate.
//
// Requirement trace:
//   Verification-plan row: SWE4-UV-041 (swe4-unit-verification-plan.md).
//   SDD-106 (Android runtime acquisition of RECORD_AUDIO; bridge event
//   surface §5; openAppSettings deep-link §3).
//   SRS-209 (runtime microphone permission acquisition, fail-safe to
//   listen-only on denial, user-visible path to grant).
//
// Strategy: Following the pattern in `back_intent_service_test.dart`,
// use `TestDefaultBinaryMessengerBinding` to (a) capture outbound
// `requestRecordAudio` / `openAppSettings` invocations and (b) inject
// inbound `permissionStateChanged` calls as if Kotlin had emitted them.
//
// Platform note: these tests run on the host (Linux), where
// `Platform.isAndroid` is false. The production constructor's default
// `_channel` therefore becomes `null` on the host. We bypass that by
// injecting an explicit `MethodChannel` via the constructor's
// `channel:` parameter so that the inbound/outbound contract under
// test is actually exercised. The non-Android short-circuit (channel
// == null) is covered explicitly by the final test.

import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';

import 'package:chanora_flutter/services/android_permissions_service.dart';

void main() {
  TestWidgetsFlutterBinding.ensureInitialized();

  late MethodChannel channel;
  late List<MethodCall> outgoingCalls;

  // Optional override for the outbound mock handler. When null, the
  // mock returns null (no-op) and does not synthesise an inbound
  // `permissionStateChanged` callback.
  Future<Object?> Function(MethodCall call)? outgoingResponder;

  /// Helper: simulate Kotlin invoking `permissionStateChanged` on the
  /// Dart-installed handler. Mirrors what `AndroidPermissionRequester.kt`
  /// does at runtime.
  Future<void> sendPermissionStateChanged({
    required String permission,
    required String state,
  }) async {
    const codec = StandardMethodCodec();
    final encoded = codec.encodeMethodCall(
      MethodCall(methodPermissionStateChanged, <String, dynamic>{
        'permission': permission,
        'state': state,
      }),
    );
    await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .handlePlatformMessage(channel.name, encoded, (_) {});
  }

  setUp(() {
    channel = const MethodChannel(androidPermissionsChannelName);
    outgoingCalls = <MethodCall>[];
    outgoingResponder = null;

    // Capture outbound invokeMethod calls (requestRecordAudio,
    // openAppSettings) by installing a mock on the channel's outgoing
    // side. The default returns null; tests that need a richer
    // response set `outgoingResponder`.
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, (call) async {
          outgoingCalls.add(call);
          final r = outgoingResponder;
          if (r != null) return r(call);
          return null;
        });
  });

  tearDown(() {
    TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
        .setMockMethodCallHandler(channel, null);
  });

  test('SWE4-UV-041 / SDD-106: a fresh service exposes a deterministic '
      'initial recordAudioState (granted on non-Android host, where these '
      'unit tests run; the Android cold-launch case is unknown)', () {
    // Trace: SDD-106 §5 state machine; non-Android short-circuit
    // documented in android_permissions_service.dart's library
    // doc-comment ("default state is granted" off-Android).
    final svc = AndroidPermissionsService(channel: channel);
    // On the linux host where flutter test runs, Platform.isAndroid is
    // false, so the ValueNotifier seeds to granted regardless of
    // whether a channel was injected. This documents that contract.
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.granted,
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
      'state=Granted transitions recordAudioState to granted and notifies '
      'listeners', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    // Drive away from granted first so the assertion is meaningful.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );

    var notified = 0;
    void listener() => notified++;
    svc.recordAudioState.addListener(listener);

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Granted',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.granted,
    );
    expect(notified, greaterThanOrEqualTo(1));

    svc.recordAudioState.removeListener(listener);
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106 §5: inbound permissionStateChanged with '
      'state=Denied transitions recordAudioState to denied', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106 §3: inbound permissionStateChanged with '
      'state=PermanentlyDenied transitions recordAudioState to '
      'permanentlyDenied (drives the "open app settings" UX)', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'PermanentlyDenied',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.permanentlyDenied,
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106 §5: a malformed state string parses to '
      'unknown (the _parseState default branch)', () async {
    // Implementation contract: `_parseState` returns `unknown` for any
    // string outside {Granted, Denied, PermanentlyDenied}.
    final svc = AndroidPermissionsService(channel: channel)..start();

    // Start from a non-unknown baseline so the transition is observable.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Granted',
    );
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.granted,
    );

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Bogus',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.unknown,
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106: inbound permissionStateChanged for a '
      'permission other than RECORD_AUDIO is ignored (POST_NOTIFICATIONS '
      'will route through a sibling listenable per the impl note)', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    // Seed a known baseline.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );

    // Now send a POST_NOTIFICATIONS message claiming Granted; the
    // RECORD_AUDIO state must not move.
    await sendPermissionStateChanged(
      permission: 'android.permission.POST_NOTIFICATIONS',
      state: 'Granted',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
      reason: 'POST_NOTIFICATIONS must not mutate the RECORD_AUDIO listenable',
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106: start() is idempotent — calling it twice '
      'does not double-register the handler and inbound messages still '
      'fire exactly once', () async {
    final svc = AndroidPermissionsService(channel: channel)
      ..start()
      ..start(); // Second call must be a no-op.

    var notified = 0;
    void listener() => notified++;
    svc.recordAudioState.addListener(listener);

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );
    // A single inbound call → a single value change → exactly one
    // listener notification (would be two if the handler had been
    // double-registered).
    expect(notified, 1);

    svc.recordAudioState.removeListener(listener);
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106: stop() removes the handler — subsequent '
      'inbound messages have no effect on recordAudioState', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );

    svc.stop();

    // After stop(): inbound message must NOT mutate state.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Granted',
    );

    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
      reason: 'state must be frozen after stop()',
    );
  });

  test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() emits an outbound '
      'requestRecordAudio MethodCall on the channel; when the platform '
      'stub raises (e.g. handler missing in a debug build) the impl '
      'catches and resolves to the current cached state', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    // Seed a baseline so the return value is observable & deterministic.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );

    // Make the platform stub throw to drive the impl's catch-branch
    // (see android_permissions_service.dart: "Channel-side failure
    // (e.g. missing handler in a debug build). Fall back to whatever
    // state we currently hold"). This is the deterministic, fast path
    // to verify the outbound call is emitted without depending on a
    // synthesised inbound transition.
    outgoingResponder = (call) async {
      throw PlatformException(code: 'unavailable');
    };

    final priorOutgoing = outgoingCalls.length;
    final result = await svc.ensureRecordAudio();

    final requests = outgoingCalls
        .skip(priorOutgoing)
        .where((c) => c.method == methodRequestRecordAudio)
        .toList();
    expect(
      requests,
      hasLength(1),
      reason: 'ensureRecordAudio() must invoke requestRecordAudio',
    );
    expect(
      result,
      AndroidRecordAudioPermissionState.denied,
      reason:
          'on channel failure ensureRecordAudio must return the cached state',
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() resolves with the '
      'new state when an inbound permissionStateChanged is delivered '
      'while the request is in-flight (the realistic Kotlin flow)', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    // Seed away from the host default (granted on linux) so that the
    // subsequent Granted emission is an actual ValueNotifier
    // transition, which is what the in-flight listener inside
    // ensureRecordAudio relies on.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Denied',
    );
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.denied,
    );

    // Kick off the request without awaiting; the listener inside
    // ensureRecordAudio is registered synchronously before the
    // invokeMethod call yields.
    final pending = svc.ensureRecordAudio();

    // Yield once so the outgoing invokeMethod can dispatch and our
    // capture-mock can record it.
    await Future<void>.delayed(Duration.zero);
    expect(
      outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
      hasLength(1),
      reason: 'outgoing requestRecordAudio must be observable mid-flight',
    );

    // Now simulate Kotlin emitting the resolved state.
    await sendPermissionStateChanged(
      permission: 'android.permission.RECORD_AUDIO',
      state: 'Granted',
    );

    final result = await pending;

    expect(result, AndroidRecordAudioPermissionState.granted);
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.granted,
    );
    svc.dispose();
  });

  test(
    'SWE4-UV-041 / SDD-106 §1: ensureRecordAudio() short-circuits when '
    'the cached state is already granted',
    () async {
      final svc = AndroidPermissionsService(channel: channel)..start();

      await sendPermissionStateChanged(
        permission: 'android.permission.RECORD_AUDIO',
        state: 'Granted',
      );
      expect(
        svc.recordAudioState.value,
        AndroidRecordAudioPermissionState.granted,
      );

      outgoingResponder = (call) async => 'Granted';

      final result = await svc.ensureRecordAudio();

      expect(result, AndroidRecordAudioPermissionState.granted);
      expect(
        outgoingCalls.where((c) => c.method == methodRequestRecordAudio),
        isEmpty,
      );
      svc.dispose();
    },
  );

  test('SWE4-UV-041 / SDD-106 §3: openAppSettings() emits an outbound '
      'openAppSettings MethodCall on the channel', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    await svc.openAppSettings();

    final calls = outgoingCalls
        .where((c) => c.method == methodOpenAppSettings)
        .toList();
    expect(calls, hasLength(1));
    svc.dispose();
  });

  test('startup permissions request emits outbound method and records '
      'multiple returned states', () async {
    final svc = AndroidPermissionsService(channel: channel)..start();

    outgoingResponder = (call) async {
      if (call.method == methodRequestStartupPermissions) {
        return {
          'android.permission.RECORD_AUDIO': 'Granted',
          'android.permission.BLUETOOTH_CONNECT': 'Denied',
          'android.permission.POST_NOTIFICATIONS': 'PermanentlyDenied',
        };
      }
      return null;
    };

    final result = await svc.ensureStartupPermissions();

    expect(
      outgoingCalls.where((c) => c.method == methodRequestStartupPermissions),
      hasLength(1),
    );
    expect(
      result[AndroidPermissionKind.recordAudio],
      AndroidRecordAudioPermissionState.granted,
    );
    expect(
      result[AndroidPermissionKind.bluetoothConnect],
      AndroidRecordAudioPermissionState.denied,
    );
    expect(
      result[AndroidPermissionKind.postNotifications],
      AndroidRecordAudioPermissionState.permanentlyDenied,
    );
    expect(
      svc.permissionState(AndroidPermissionKind.bluetoothConnect).value,
      AndroidRecordAudioPermissionState.denied,
    );
    svc.dispose();
  });

  test('SWE4-UV-041 / SDD-106: non-Android short-circuit — when channel '
      'is null, recordAudioState seeds to granted and ensureRecordAudio '
      'resolves synchronously without touching any channel', () async {
    final svc = AndroidPermissionsService(channel: null);

    // Per the library doc: "The state listenable stays at granted so
    // the voice-join gate becomes a no-op on those platforms."
    expect(
      svc.recordAudioState.value,
      AndroidRecordAudioPermissionState.granted,
    );

    // start()/stop() must be no-ops (not throw) when channel is null.
    svc.start();
    svc.stop();

    final priorOutgoing = outgoingCalls.length;
    final result = await svc.ensureRecordAudio();

    expect(result, AndroidRecordAudioPermissionState.granted);
    expect(
      outgoingCalls.length,
      priorOutgoing,
      reason: 'no outbound channel calls when channel is null',
    );

    // openAppSettings must also be a silent no-op.
    await svc.openAppSettings();
    expect(outgoingCalls.length, priorOutgoing);

    svc.dispose();
  });
}
