import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:nomadcode_app/src/app/nomadcode_client_app.dart';
import 'package:nomadcode_app/src/features/workspaces/domain/project_workspace.dart';
import 'package:nomadcode_app/src/features/workspaces/domain/workspace_task.dart';
import 'package:nomadcode_app/src/features/workspaces/presentation/workspace_home_page.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_endpoint_config.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_envelope.dart';
import 'package:nomadcode_app/src/integrations/proto_socket/proto_socket_lifecycle.dart';

const _enabledProtoSocketConfig = ProtoSocketEndpointConfig(
  host: 'core.example.com',
  port: 443,
  secure: true,
  enabled: true,
);

const _disabledProtoSocketConfig = ProtoSocketEndpointConfig(
  host: 'core.example.com',
  port: 443,
  secure: true,
);

class _FakeProtoSocketTransport implements ProtoSocketTransport {
  final StreamController<ProtoSocketEnvelope> controller =
      StreamController<ProtoSocketEnvelope>.broadcast();
  final List<Map<String, Object?>> taskMaps = const [
    {
      'id': 'task-service-1',
      'title': 'Service task from Core',
      'status': 'running',
      'source': 'core',
    },
  ];
  final List<ProtoSocketEnvelope> sent = [];

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

  @override
  Future<ProtoSocketEnvelope> sendRequest(
    ProtoSocketEnvelope envelope, {
    Duration timeout = const Duration(seconds: 30),
  }) async {
    sent.add(envelope);
    if (envelope.action != 'task.list') {
      throw StateError('unexpected action ${envelope.action}');
    }
    return ProtoSocketEnvelope(
      id: 'response-${sent.length}',
      correlationId: envelope.id,
      type: 'response',
      channel: 'task',
      action: envelope.action,
      payload: {'tasks': taskMaps},
      meta: const {
        'connection_id': 'conn-widget',
        'timestamp': '2026-05-30T12:02:00Z',
      },
    );
  }

  @override
  Future<void> close() => controller.close();
}

class _FakeProtoSocketConnector implements ProtoSocketConnector {
  final _FakeProtoSocketTransport transport;
  final Completer<ProtoSocketTransport>? pendingConnect;
  int connectCalls = 0;
  int disconnectCalls = 0;

  _FakeProtoSocketConnector({
    _FakeProtoSocketTransport? transport,
    this.pendingConnect,
  }) : transport = transport ?? _FakeProtoSocketTransport();

  @override
  Future<ProtoSocketTransport> connect(ProtoSocketEndpointConfig config) {
    connectCalls += 1;
    final pending = pendingConnect;
    if (pending != null) return pending.future;
    return Future.value(transport);
  }

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

/// Pumps a bounded number of frames so async connect/load work settles without
/// `pumpAndSettle`, which never returns while the loading
/// `CircularProgressIndicator` animation is on screen.
Future<void> _pumpFrames(
  WidgetTester tester, {
  int frames = 8,
  Duration step = const Duration(milliseconds: 50),
}) async {
  for (var i = 0; i < frames; i++) {
    await tester.pump(step);
  }
}

void main() {
  testWidgets('App renders console dashboard title', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(const NomadCodeClientApp());

    expect(find.text('NomadCode Console'), findsOneWidget);
    expect(find.text('NomadCode Core'), findsNWidgets(2));
    expect(find.text('IOP Bridge'), findsOneWidget);
  });

  testWidgets('Workbench opens agent dock and IOP section from right rail', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(const NomadCodeClientApp());

    await tester.tap(find.byIcon(Icons.smart_toy_outlined));
    await tester.pump();

    expect(find.text('Ask the workspace agent'), findsOneWidget);
    expect(find.textContaining('NomadCode agent is docked'), findsOneWidget);

    await tester.tap(find.byIcon(Icons.hub_outlined));
    await tester.pump();

    expect(find.text('IOP'), findsOneWidget);
    expect(find.textContaining('Optional IOP console package'), findsOneWidget);
  });

  testWidgets('Workbench separates titlebar, right rail, and content', (
    WidgetTester tester,
  ) async {
    addTearDown(() {
      tester.view.resetPhysicalSize();
      tester.view.resetDevicePixelRatio();
    });

    for (final size in const [Size(390, 800), Size(1280, 900)]) {
      tester.view.physicalSize = size;
      tester.view.devicePixelRatio = 1;
      await tester.pumpWidget(const NomadCodeClientApp());
      await tester.pump();

      final titleRect = tester.getRect(find.text('NomadCode Console'));
      final workflowRailRect = tester.getRect(find.byTooltip('Workflow'));

      expect(titleRect.bottom, lessThanOrEqualTo(workflowRailRect.top));

      await tester.tap(find.byTooltip('Workflow'));
      await tester.pump();

      final workflowContentRect = tester.getRect(
        find.text('Workflow management surface will mount here.'),
      );

      expect(workflowContentRect.top, greaterThan(titleRect.bottom));
      expect(
        workflowContentRect.right,
        lessThanOrEqualTo(workflowRailRect.left),
      );

      await tester.tap(find.byTooltip('Agent'));
      await tester.pump();

      final agentRailRect = tester.getRect(find.byTooltip('Agent'));
      final agentContentRect = tester.getRect(
        find.text('Ask the workspace agent'),
      );

      expect(agentContentRect.top, greaterThan(titleRect.bottom));
      expect(agentContentRect.right, lessThanOrEqualTo(agentRailRect.left));

      await tester.pumpWidget(const SizedBox.shrink());
      await tester.pump();
    }
  });

  testWidgets('WorkspaceHomePage triggers launcher callback on button tap', (
    WidgetTester tester,
  ) async {
    ProjectWorkspace? launchedProject;

    await tester.pumpWidget(
      MaterialApp(
        home: WorkspaceHomePage(
          onLaunchCodeServer: (project) async {
            launchedProject = project;
            return true;
          },
        ),
      ),
    );

    final buttonFinder = find.widgetWithText(ElevatedButton, 'Open Workspace');
    expect(buttonFinder, findsOneWidget);

    await tester.tap(buttonFinder);
    await tester.pumpAndSettle();

    expect(launchedProject, isNotNull);
    expect(launchedProject!.id, equals('nomadcode'));
  });

  testWidgets('WorkspaceHomePage renders tasks from the loader', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(
      MaterialApp(
        home: WorkspaceHomePage(
          loadTasks: (project) async => const [
            WorkspaceTask(
              id: 't1',
              title: 'Build pipeline',
              status: 'running',
              source: 'core',
            ),
            WorkspaceTask(
              id: 't2',
              title: 'Sync contracts',
              status: 'queued',
              source: 'core',
            ),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Build pipeline'), findsOneWidget);
    expect(find.text('Sync contracts'), findsOneWidget);
    expect(find.text('running'), findsOneWidget);
    expect(find.text('queued'), findsOneWidget);
  });

  testWidgets('WorkspaceHomePage renders empty task state from the loader', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(
      MaterialApp(
        home: WorkspaceHomePage(
          loadTasks: (project) async => const <WorkspaceTask>[],
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('No active tasks. All tasks are idle.'), findsOneWidget);
  });

  testWidgets('WorkspaceHomePage renders error state with retry on failure', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(
      MaterialApp(
        home: WorkspaceHomePage(
          loadTasks: (project) async =>
              throw Exception('secret transport boom'),
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Failed to load tasks.'), findsOneWidget);
    expect(find.widgetWithText(TextButton, 'Retry'), findsOneWidget);
    // The underlying exception detail must not leak into the UI.
    expect(find.textContaining('secret transport boom'), findsNothing);
  });

  testWidgets('WorkspaceHomePage shows proto-socket diagnostics safely', (
    WidgetTester tester,
  ) async {
    final connector = _FakeProtoSocketConnector();
    final lifecycle = ProtoSocketLifecycle(connector: connector);
    await lifecycle.connect(_enabledProtoSocketConfig);

    await tester.pumpWidget(
      MaterialApp(home: WorkspaceHomePage(protoSocketLifecycle: lifecycle)),
    );
    await tester.pump();

    connector.transport.controller.add(
      const ProtoSocketEnvelope(
        id: 'event-secret',
        type: 'event',
        channel: 'task',
        action: 'task.status.changed',
        error: ProtoSocketEnvelopeError(
          code: 'task.conflict',
          message: 'secret server detail',
          retryable: false,
        ),
        payload: {'secret': 'do-not-render'},
        meta: {
          'connection_id': 'conn-debug',
          'timestamp': '2026-05-30T12:03:00Z',
        },
      ),
    );
    // The diagnostics event crosses two broadcast streams (transport ->
    // lifecycle -> StreamBuilder); pump twice so the delivered event also gets
    // a frame to rebuild the debug surface.
    await tester.pump();
    await tester.pump();

    expect(find.text('PROTO-SOCKET'), findsOneWidget);
    expect(find.text('connected'), findsOneWidget);
    expect(find.text('conn-debug'), findsOneWidget);
    expect(find.text(protoSocketProtocolVersion), findsOneWidget);
    expect(find.text('task'), findsOneWidget);
    expect(find.text('task.status.changed'), findsOneWidget);
    expect(find.text('task.conflict'), findsOneWidget);
    expect(find.text('2026-05-30T12:03:00Z'), findsOneWidget);
    expect(find.textContaining('do-not-render'), findsNothing);
    expect(find.textContaining('secret server detail'), findsNothing);

    addTearDown(lifecycle.dispose);
  });

  testWidgets('reloads tasks when loader is attached after initial build', (
    WidgetTester tester,
  ) async {
    await tester.pumpWidget(
      const MaterialApp(home: WorkspaceHomePage(loadTasks: null)),
    );
    await tester.pumpAndSettle();

    expect(find.text('Attached loader task'), findsNothing);

    await tester.pumpWidget(
      MaterialApp(
        home: WorkspaceHomePage(
          loadTasks: (project) async => const [
            WorkspaceTask(
              id: 'attached-1',
              title: 'Attached loader task',
              status: 'queued',
              source: 'core',
            ),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Attached loader task'), findsOneWidget);
    expect(find.text('queued'), findsOneWidget);
  });

  group('NomadCodeClientApp', () {
    testWidgets('does not connect when proto-socket config is absent', (
      WidgetTester tester,
    ) async {
      final connector = _FakeProtoSocketConnector();
      final lifecycle = ProtoSocketLifecycle(connector: connector);

      await tester.pumpWidget(
        NomadCodeClientApp(protoSocketLifecycle: lifecycle),
      );
      await tester.pump();

      expect(connector.connectCalls, equals(0));

      await tester.pumpWidget(const SizedBox.shrink());
      await lifecycle.dispose();
    });

    testWidgets('does not connect when proto-socket config is disabled', (
      WidgetTester tester,
    ) async {
      final connector = _FakeProtoSocketConnector();
      final lifecycle = ProtoSocketLifecycle(connector: connector);

      await tester.pumpWidget(
        NomadCodeClientApp(
          protoSocketLifecycle: lifecycle,
          protoSocketConfig: _disabledProtoSocketConfig,
        ),
      );
      await tester.pump();

      expect(connector.connectCalls, equals(0));

      await tester.pumpWidget(const SizedBox.shrink());
      await lifecycle.dispose();
    });

    testWidgets('connects once and renders service task data', (
      WidgetTester tester,
    ) async {
      final connector = _FakeProtoSocketConnector();
      final lifecycle = ProtoSocketLifecycle(connector: connector);
      // Dispose via addTearDown so it runs after the tester has quiesced; a
      // manual dispose() in the body races in-flight diagnostics broadcast
      // delivery and hangs isolate shutdown.
      addTearDown(lifecycle.dispose);

      await tester.pumpWidget(
        NomadCodeClientApp(
          protoSocketLifecycle: lifecycle,
          protoSocketConfig: _enabledProtoSocketConfig,
        ),
      );
      // Cannot use pumpAndSettle: the loading FutureBuilder shows a
      // CircularProgressIndicator whose animation never settles. Pump bounded
      // frames so connect -> task load -> render completes deterministically.
      await _pumpFrames(tester);

      expect(connector.connectCalls, equals(1));
      expect(connector.transport.sent.single.action, equals('task.list'));
      expect(find.text('Service task from Core'), findsOneWidget);
      expect(find.text('running'), findsOneWidget);
      expect(find.text('conn-widget'), findsOneWidget);
      expect(find.text('task.list'), findsOneWidget);
    });

    testWidgets(
      'uses already connected lifecycle without disconnecting on dispose',
      (WidgetTester tester) async {
        final connector = _FakeProtoSocketConnector();
        final lifecycle = ProtoSocketLifecycle(connector: connector);
        addTearDown(lifecycle.dispose);
        await lifecycle.connect(_enabledProtoSocketConfig);

        await tester.pumpWidget(
          NomadCodeClientApp(
            protoSocketLifecycle: lifecycle,
            protoSocketConfig: _enabledProtoSocketConfig,
          ),
        );
        await _pumpFrames(tester);

        expect(connector.connectCalls, equals(1));
        expect(find.text('Service task from Core'), findsOneWidget);

        // Unmounting the widget must NOT disconnect a lifecycle it did not
        // start (the lifecycle was already connected before mount).
        await tester.pumpWidget(const SizedBox.shrink());
        await tester.pump();

        expect(connector.disconnectCalls, equals(0));
      },
    );

    testWidgets('cleans up late app-started connects after dispose', (
      WidgetTester tester,
    ) async {
      final pendingConnect = Completer<ProtoSocketTransport>();
      final connector = _FakeProtoSocketConnector(
        pendingConnect: pendingConnect,
      );
      final lifecycle = ProtoSocketLifecycle(connector: connector);
      addTearDown(lifecycle.dispose);

      await tester.pumpWidget(
        NomadCodeClientApp(
          protoSocketLifecycle: lifecycle,
          protoSocketConfig: _enabledProtoSocketConfig,
        ),
      );
      await tester.pump();

      expect(connector.connectCalls, equals(1));
      expect(lifecycle.state, ProtoSocketConnectionState.connecting);

      await tester.pumpWidget(const SizedBox.shrink());
      pendingConnect.complete(connector.transport);
      await tester.pump();
      await tester.pump();

      expect(connector.disconnectCalls, equals(1));
    });
  });
}
