// Copyright (c) 2023, the Dart project authors.  Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.

@Timeout(Duration(minutes: 2))
library;

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

import 'package:dwds/data/devtools_request.dart';
import 'package:dwds/data/extension_request.dart';
import 'package:dwds/data/serializers.dart';
import 'package:dwds/src/debugging/execution_context.dart';
import 'package:dwds/src/servers/extension_debugger.dart';
import 'package:test/test.dart';
import 'package:test_common/logging.dart';
import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';

import 'fixtures/fakes.dart';

void main() async {
  final debug = false;

  group('ExecutionContext', () {
    setUpAll(() {
      setCurrentLogWriter(debug: debug);
    });

    TestDebuggerConnection? debugger;
    TestDebuggerConnection getDebugger() => debugger!;

    setUp(() async {
      setCurrentLogWriter(debug: debug);
      debugger = TestDebuggerConnection();
    });

    tearDown(() async {
      await debugger?.close();
    });

    test('is created on devtools request', () async {
      final debugger = getDebugger();
      await debugger.createDebuggerExecutionContext(TestContextId.dartDefault);

      // Expect the context ID to be set.
      expect(await debugger.defaultContextId(), TestContextId.dartDefault);
    });

    test('clears context ID', () async {
      final debugger = getDebugger();
      await debugger.createDebuggerExecutionContext(TestContextId.dartDefault);

      debugger.sendContextsClearedEvent();

      // Expect non-dart context.
      expect(await debugger.defaultContextId(), TestContextId.none);
    });

    test('finds dart context ID', () async {
      final debugger = getDebugger();
      await debugger.createDebuggerExecutionContext(TestContextId.none);

      debugger.sendContextCreatedEvent(TestContextId.dartNormal);

      // Expect dart context.
      expect(await debugger.dartContextId(), TestContextId.dartNormal);
    });

    test('does not find dart context ID if not available', () async {
      final debugger = getDebugger();
      await debugger.createDebuggerExecutionContext(TestContextId.none);

      // No context IDs received yet.
      expect(await debugger.defaultContextId(), TestContextId.none);

      debugger.sendContextCreatedEvent(TestContextId.dartLate);

      // Expect no dart context.
      // This mocks injected client still loading.
      expect(await debugger.noContextId(), TestContextId.none);

      // Expect dart context.
      // This mocks injected client loading later for previously
      // received context ID.
      expect(await debugger.dartContextId(), TestContextId.dartLate);
    });

    test('works with stale contexts', () async {
      final debugger = getDebugger();
      await debugger.createDebuggerExecutionContext(TestContextId.none);

      debugger.sendContextCreatedEvent(TestContextId.stale);

      // Expect no dart context.
      expect(await debugger.noContextId(), TestContextId.none);

      debugger.sendContextsClearedEvent();
      debugger.sendContextCreatedEvent(TestContextId.dartNormal);

      // Expect dart context.
      expect(await debugger.dartContextId(), TestContextId.dartNormal);
    });
  });
}

enum TestContextId {
  none,
  dartDefault,
  dartNormal,
  dartLate,
  nonDart,
  stale;

  factory TestContextId.from(int? value) {
    return switch (value) {
      null => none,
      0 => dartDefault,
      1 => dartNormal,
      2 => dartLate,
      3 => nonDart,
      4 => stale,
      _ => throw StateError('$value is not a TestContextId'),
    };
  }

  int? get id {
    return switch (this) {
      none => null,
      dartDefault => 0,
      dartNormal => 1,
      dartLate => 2,
      nonDart => 3,
      stale => 4,
    };
  }
}

class TestExtensionDebugger extends ExtensionDebugger {
  TestExtensionDebugger(FakeSseConnection super.sseConnection);

  @override
  Future<WipResponse> sendCommand(
    String command, {
    Map<String, dynamic>? params,
  }) {
    final id = params?['contextId'];
    final response = super.sendCommand(command, params: params);

    /// Mock stale contexts that cause the evaluation to throw.
    if (command == 'Runtime.evaluate' &&
        TestContextId.from(id) == TestContextId.stale) {
      throw Exception('Stale execution context');
    }
    return response;
  }
}

class TestDebuggerConnection {
  late final TestExtensionDebugger extensionDebugger;
  late final FakeSseConnection connection;

  int _evaluateRequestId = 0;

  TestDebuggerConnection() {
    connection = FakeSseConnection();
    extensionDebugger = TestExtensionDebugger(connection);
  }

  /// Create a new execution context in the debugger.
  Future<void> createDebuggerExecutionContext(TestContextId contextId) {
    _sendDevToolsRequest(contextId: contextId.id);
    return _executionContext();
  }

  /// Flush the streams and close debugger connection.
  Future<void> close() async {
    unawaited(connection.controllerOutgoing.stream.any((e) => false));
    unawaited(extensionDebugger.devToolsRequestStream.any((e) => false));

    await connection.controllerIncoming.sink.close();
    await connection.controllerOutgoing.sink.close();

    await extensionDebugger.close();
  }

  /// Return the initial context ID from the DevToolsRequest.
  Future<TestContextId> defaultContextId() async {
    // Give the previous events time to propagate.
    await Future.delayed(Duration(milliseconds: 100));
    return TestContextId.from(await extensionDebugger.executionContext!.id);
  }

  /// Mock receiving dart context ID in the execution context.
  ///
  /// Note: dart context is detected by evaluation of
  /// `window.$dartAppInstanceId` in that context returning
  /// a non-null value.
  Future<TestContextId> dartContextId() async {
    // Try getting execution id.
    final executionContextId = extensionDebugger.executionContext!.id;

    // Give it time to send the evaluate request.
    await Future.delayed(Duration(milliseconds: 100));

    // Respond to the evaluate request.
    _sendEvaluationResponse({
      'result': {'value': 'dart'},
    });

    return TestContextId.from(await executionContextId);
  }

  /// Mock receiving non-dart context ID in the execution context.
  ///
  /// Note: dart context is detected by evaluation of
  /// `window.$dartAppInstanceId` in that context returning
  /// a null value.
  Future<TestContextId> noContextId() async {
    // Try getting execution id.
    final executionContextId = extensionDebugger.executionContext!.id;

    // Give it time to send the evaluate request.
    await Future.delayed(Duration(milliseconds: 100));

    // Respond to the evaluate request.
    _sendEvaluationResponse({
      'result': {'value': null},
    });

    return TestContextId.from(await executionContextId);
  }

  /// Send `Runtime.executionContextsCleared` event to the execution
  /// context in the extension debugger.
  void sendContextsClearedEvent() {
    final extensionEvent = ExtensionEvent(
      (b) => b
        ..method = jsonEncode('Runtime.executionContextsCleared')
        ..params = jsonEncode({}),
    );
    connection.controllerIncoming.sink
        .add(jsonEncode(serializers.serialize(extensionEvent)));
  }

  /// Send `Runtime.executionContextCreated` event to the execution
  /// context in the extension debugger.
  void sendContextCreatedEvent(TestContextId contextId) {
    final extensionEvent = ExtensionEvent(
      (b) => b
        ..method = jsonEncode('Runtime.executionContextCreated')
        ..params = jsonEncode({
          'context': {'id': '${contextId.id}'},
        }),
    );
    connection.controllerIncoming.sink
        .add(jsonEncode(serializers.serialize(extensionEvent)));
  }

  void _sendEvaluationResponse(Map<String, dynamic> response) {
    // Respond to the evaluate request.
    final extensionResponse = ExtensionResponse(
      (b) => b
        ..result = jsonEncode(response)
        ..id = _evaluateRequestId++
        ..success = true,
    );
    connection.controllerIncoming.sink
        .add(jsonEncode(serializers.serialize(extensionResponse)));
  }

  void _sendDevToolsRequest({int? contextId}) {
    final devToolsRequest = DevToolsRequest(
      (b) => b
        ..contextId = contextId
        ..appId = 'app'
        ..instanceId = '0',
    );
    connection.controllerIncoming.sink
        .add(jsonEncode(serializers.serialize(devToolsRequest)));
  }

  Future<void> _executionContext() async {
    final executionContext = await _waitForExecutionContext().timeout(
      const Duration(milliseconds: 100),
      onTimeout: () {
        expect(fail, 'Timeout getting execution context');
        return null;
      },
    );
    expect(executionContext, isNotNull);
  }

  Future<ExecutionContext?> _waitForExecutionContext() async {
    while (extensionDebugger.executionContext == null) {
      await Future.delayed(Duration(milliseconds: 20));
    }
    return extensionDebugger.executionContext;
  }
}
