import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_client.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart';
import 'package:protobuf/well_known_types/google/protobuf/struct.pb.dart';

class _FakeTransport implements ProtoSocketTransport {
  final StreamController<ProtoSocketEnvelope> controller =
      StreamController<ProtoSocketEnvelope>.broadcast();
  int sendCalls = 0;
  int closeCalls = 0;
  ProtoSocketEnvelope? response;

  @override
  Stream<ProtoSocketEnvelope> get events => controller.stream;

  @override
  Future<ProtoSocketEnvelope> sendRequest(
    ProtoSocketEnvelope envelope, {
    Duration timeout = const Duration(seconds: 30),
  }) async {
    sendCalls += 1;
    return response ?? envelope;
  }

  @override
  Future<void> close() async {
    closeCalls += 1;
    await controller.close();
  }
}

class _FakeConnector implements ProtoSocketConnector {
  int connectCalls = 0;
  int disconnectCalls = 0;
  Object? throwOnConnect;
  _FakeTransport transport = _FakeTransport();

  @override
  Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) async {
    connectCalls += 1;
    final err = throwOnConnect;
    if (err != null) throw err;
    return transport;
  }

  @override
  Future<void> disconnect() async {
    disconnectCalls += 1;
  }
}

class _FakePeer implements NomadProtoSocketPeer {
  final StreamController<ProtoSocketEnvelope> controller =
      StreamController<ProtoSocketEnvelope>.broadcast();
  ProtoSocketEnvelope? lastRequest;
  Duration? lastTimeout;
  int closeCalls = 0;

  @override
  Stream<ProtoSocketEnvelope> get envelopeEvents => controller.stream;

  @override
  Future<ProtoSocketEnvelope> sendEnvelopeRequest(
    ProtoSocketEnvelope envelope, {
    Duration timeout = const Duration(seconds: 30),
  }) async {
    lastRequest = envelope;
    lastTimeout = timeout;
    return ProtoSocketEnvelope(
      id: envelope.id,
      correlationId: envelope.correlationId,
      type: 'response',
      channel: envelope.channel,
      action: envelope.action,
      payload: const {'ok': true},
    );
  }

  @override
  Future<void> close() async {
    closeCalls += 1;
    await controller.close();
  }
}

void main() {
  const config = ProtoSocketEndpointConfig(
    host: 'core.example.com',
    port: 443,
    secure: true,
  );

  test('happy path emits connecting -> connected -> disconnected', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);
    final states = <ProtoSocketConnectionState>[];
    final sub = lifecycle.stateStream.listen(states.add);

    expect(lifecycle.state, ProtoSocketConnectionState.disconnected);

    await lifecycle.connect(config);
    expect(lifecycle.state, ProtoSocketConnectionState.connected);
    expect(lifecycle.transport, isNotNull);
    expect(fake.connectCalls, equals(1));

    await lifecycle.disconnect();
    expect(lifecycle.state, ProtoSocketConnectionState.disconnected);
    expect(lifecycle.transport, isNull);
    expect(fake.disconnectCalls, equals(1));

    await Future<void>.delayed(Duration.zero);
    await sub.cancel();
    await lifecycle.dispose();

    expect(states, [
      ProtoSocketConnectionState.connecting,
      ProtoSocketConnectionState.connected,
      ProtoSocketConnectionState.disconnected,
    ]);
  });

  test('records response diagnostics from transport requests', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);
    fake.transport.response = const ProtoSocketEnvelope(
      id: 'resp-1',
      correlationId: 'req-1',
      type: 'response',
      channel: 'task',
      action: 'task.list',
      meta: {
        'connection_id': 'conn-response',
        'timestamp': '2026-05-30T12:00:00Z',
      },
      payload: {'secret': 'not-for-diagnostics'},
    );

    await lifecycle.connect(config);
    await lifecycle.transport!.sendRequest(
      const ProtoSocketEnvelope(
        id: 'req-1',
        type: 'request',
        channel: 'task',
        action: 'task.list',
      ),
    );

    expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.connected);
    expect(lifecycle.diagnostics.connectionId, equals('conn-response'));
    expect(
      lifecycle.diagnostics.protocolVersion,
      equals(protoSocketProtocolVersion),
    );
    expect(lifecycle.diagnostics.channel, equals('task'));
    expect(lifecycle.diagnostics.action, equals('task.list'));
    expect(lifecycle.diagnostics.errorCode, isNull);
    expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:00:00Z'));

    await lifecycle.dispose();
  });

  test('records event diagnostics with error codes', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.connect(config);
    fake.transport.controller.add(
      const ProtoSocketEnvelope(
        id: 'event-1',
        type: 'event',
        channel: 'task',
        action: 'task.status.changed',
        error: ProtoSocketEnvelopeError(
          code: 'task.conflict',
          message: 'payload detail is private',
          retryable: false,
        ),
        meta: {
          'connection_id': 'conn-event',
          'timestamp': '2026-05-30T12:01:00Z',
        },
        payload: {'token': 'do-not-display'},
      ),
    );
    await Future<void>.delayed(Duration.zero);

    expect(lifecycle.diagnostics.connectionId, equals('conn-event'));
    expect(lifecycle.diagnostics.channel, equals('task'));
    expect(lifecycle.diagnostics.action, equals('task.status.changed'));
    expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
    expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:01:00Z'));

    await lifecycle.dispose();
  });

  test('disconnect clears stale connection diagnostics', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.connect(config);
    fake.transport.controller.add(
      const ProtoSocketEnvelope(
        id: 'event-1',
        type: 'event',
        channel: 'task',
        action: 'task.status.changed',
        error: ProtoSocketEnvelopeError(
          code: 'task.conflict',
          message: 'payload detail is private',
          retryable: false,
        ),
        meta: {
          'connection_id': 'conn-stale',
          'timestamp': '2026-05-30T12:00:00Z',
        },
      ),
    );
    await Future<void>.delayed(Duration.zero);
    expect(lifecycle.diagnostics.connectionId, equals('conn-stale'));

    await lifecycle.disconnect();

    expect(
      lifecycle.diagnostics.state,
      ProtoSocketConnectionState.disconnected,
    );
    expect(lifecycle.diagnostics.connectionId, isNull);
    expect(lifecycle.diagnostics.channel, isNull);
    expect(lifecycle.diagnostics.action, isNull);
    expect(lifecycle.diagnostics.errorCode, isNull);
    expect(lifecycle.diagnostics.timestamp, isNull);
    // Protocol version is a constant, not connection-scoped, so it survives.
    expect(
      lifecycle.diagnostics.protocolVersion,
      equals(protoSocketProtocolVersion),
    );

    await lifecycle.dispose();
  });

  test('reconnect start clears prior connection diagnostics', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.connect(config);
    fake.transport.controller.add(
      const ProtoSocketEnvelope(
        id: 'event-old',
        type: 'event',
        channel: 'task',
        action: 'task.status.changed',
        meta: {
          'connection_id': 'conn-old',
          'timestamp': '2026-05-30T12:00:00Z',
        },
      ),
    );
    await Future<void>.delayed(Duration.zero);
    expect(lifecycle.diagnostics.connectionId, equals('conn-old'));
    await lifecycle.disconnect();

    // The connecting transition during reconnect must not carry the prior
    // connection id, channel, action, or timestamp into the debug surface.
    final emitted = <ProtoSocketDiagnostics>[];
    final sub = lifecycle.diagnosticsStream.listen(emitted.add);
    await lifecycle.connect(config);
    await sub.cancel();

    final connecting = emitted.firstWhere(
      (d) => d.state == ProtoSocketConnectionState.connecting,
    );
    expect(connecting.connectionId, isNull);
    expect(connecting.channel, isNull);
    expect(connecting.action, isNull);
    expect(connecting.errorCode, isNull);
    expect(connecting.timestamp, isNull);

    await lifecycle.dispose();
  });

  test('failed reconnect clears prior connection diagnostics', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.connect(config);
    fake.transport.controller.add(
      const ProtoSocketEnvelope(
        id: 'event-old',
        type: 'event',
        channel: 'task',
        action: 'task.status.changed',
        error: ProtoSocketEnvelopeError(
          code: 'task.conflict',
          message: 'payload detail is private',
          retryable: false,
        ),
        meta: {
          'connection_id': 'conn-old',
          'timestamp': '2026-05-30T12:02:00Z',
        },
      ),
    );
    await Future<void>.delayed(Duration.zero);
    expect(lifecycle.diagnostics.connectionId, equals('conn-old'));
    expect(lifecycle.diagnostics.channel, equals('task'));
    expect(lifecycle.diagnostics.action, equals('task.status.changed'));
    expect(lifecycle.diagnostics.errorCode, equals('task.conflict'));
    expect(lifecycle.diagnostics.timestamp, equals('2026-05-30T12:02:00Z'));

    await lifecycle.disconnect();
    fake.throwOnConnect = StateError('reconnect failed');

    await expectLater(lifecycle.connect(config), throwsA(isA<StateError>()));

    expect(lifecycle.diagnostics.state, ProtoSocketConnectionState.failed);
    expect(lifecycle.diagnostics.connectionId, isNull);
    expect(lifecycle.diagnostics.channel, isNull);
    expect(lifecycle.diagnostics.action, isNull);
    expect(lifecycle.diagnostics.errorCode, isNull);
    expect(lifecycle.diagnostics.timestamp, isNull);

    await lifecycle.dispose();
  });

  test('connect failure transitions to failed and records error', () async {
    final fake = _FakeConnector()..throwOnConnect = StateError('boom');
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await expectLater(lifecycle.connect(config), throwsA(isA<StateError>()));
    expect(lifecycle.state, ProtoSocketConnectionState.failed);
    expect(lifecycle.lastError, isA<StateError>());
    expect(lifecycle.transport, isNull);

    await lifecycle.dispose();
  });

  test('connect is a no-op when already connecting or connected', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.connect(config);
    await lifecycle.connect(config);

    expect(fake.connectCalls, equals(1));
    await lifecycle.dispose();
  });

  test('disconnect from disconnected state is a no-op', () async {
    final fake = _FakeConnector();
    final lifecycle = ProtoSocketLifecycle(connector: fake);

    await lifecycle.disconnect();
    expect(fake.disconnectCalls, equals(0));
    expect(lifecycle.state, ProtoSocketConnectionState.disconnected);

    await lifecycle.dispose();
  });

  test(
    'RealProtoSocketConnector uses injected factories without sockets',
    () async {
      final fakeSocket = Object();
      final fakeTransport = _FakeTransport();
      ProtoSocketEndpointConfig? seenConfig;
      Object? seenSocket;
      final connector = RealProtoSocketConnector(
        socketFactory: (config) async {
          seenConfig = config;
          return fakeSocket;
        },
        transportFactory: (socket, config) {
          seenSocket = socket;
          expect(config, same(seenConfig));
          return fakeTransport;
        },
      );

      final transport = await connector.connect(config);
      expect(seenConfig, same(config));
      expect(seenSocket, same(fakeSocket));
      expect(transport, same(fakeTransport));

      await connector.disconnect();
      expect(fakeTransport.closeCalls, equals(1));
    },
  );

  test('Struct parser map supports short and qualified names', () {
    final parsers = protoSocketStructParserMap();
    final qualifiedName = Struct.getDefault().info_.qualifiedMessageName;
    final struct = ProtoSocketEnvelope(
      id: 'msg-1',
      type: 'event',
      channel: 'task',
      action: 'task.status.changed',
      payload: const {'id': 'task-1'},
    ).toStruct();

    expect(parsers[(Struct).toString()], isNotNull);
    expect(parsers[qualifiedName], isNotNull);
    expect(
      parsers[(Struct).toString()]!(struct.writeToBuffer()),
      isA<Struct>(),
    );
    expect(parsers[qualifiedName]!(struct.writeToBuffer()), isA<Struct>());
  });

  test('ProtoSocketClientTransport delegates requests and close', () async {
    final peer = _FakePeer();
    final transport = ProtoSocketClientTransport(peer);
    const request = ProtoSocketEnvelope(
      id: 'req-1',
      type: 'request',
      channel: 'task',
      action: 'task.list',
    );

    final response = await transport.sendRequest(
      request,
      timeout: const Duration(seconds: 5),
    );

    expect(peer.lastRequest, same(request));
    expect(peer.lastTimeout, equals(const Duration(seconds: 5)));
    expect(response.type, equals('response'));
    expect(response.payload, equals({'ok': true}));

    await transport.close();
    expect(peer.closeCalls, equals(1));
  });
}
