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

import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:oto_console/oto_console.dart';

import 'package:oto_client/src/app/core_connection_client.dart';
import 'package:oto_client/src/app/oto_client_app.dart';

void main() {
  testWidgets('OTO client hosts embeddable console widgets', (tester) async {
    await tester.pumpWidget(OtoClientApp(coreClient: _FakeCoreClient()));
    await tester.pump();

    expect(find.text('Build Operations Overview'), findsOneWidget);
    expect(find.text('CORE ONLINE'), findsOneWidget);
    expect(find.text('Health'), findsOneWidget);
    expect(find.text('Readiness'), findsOneWidget);
    expect(find.byTooltip('Agent'), findsOneWidget);

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

    expect(find.textContaining('OTO agent surface is ready'), findsOneWidget);
    expect(find.textContaining('Runner Registry'), findsOneWidget);
  });

  test(
    'OtoHttpCoreReadClient maps Core read responses to data states',
    () async {
      final requestedPaths = <String>[];
      final client = OtoHttpCoreReadClient(
        httpClient: MockClient((request) async {
          requestedPaths.add(request.url.path);
          return switch (request.url.path) {
            '/api/v1/runners/runner-1' => _jsonResponse({
              'runner_id': 'runner-1',
              'alias': 'mac-mini',
              'protocol_version': 'oto.runner.v1',
              'status': 'online',
              'accepted_at': '2026-06-17T01:00:00Z',
              'first_heartbeat_at': '2026-06-17T01:01:00Z',
              'last_heartbeat_at': '2026-06-17T01:02:00Z',
              'failure_reason': '',
            }),
            '/api/v1/runners/runner-1/status' => _jsonResponse({
              'accepted': true,
              'runner_id': 'runner-1',
              'status': 'online',
              'current_job_id': 'job-1',
              'current_execution_id': 'exec-1',
              'message': '',
            }),
            '/api/v1/jobs/job-1' => _jsonResponse({
              'id': 'job-1',
              'name': 'build',
              'state': 'running',
              'created_at': '2026-06-17T01:00:00Z',
              'updated_at': '2026-06-17T01:02:00Z',
              'execution_id': 'exec-1',
              'run_request': {'pipeline_yaml_path': '/pipelines/build.yaml'},
            }),
            '/api/v1/executions/exec-1' => _jsonResponse({
              'id': 'exec-1',
              'job_id': 'job-1',
              'state': 'running',
              'created_at': '2026-06-17T01:00:00Z',
              'updated_at': '2026-06-17T01:02:00Z',
              'execution_id': 'exec-1',
            }),
            '/api/v1/executions/exec-1/logs' => _jsonResponse({
              'logs': [
                {'Timestamp': '2026-06-17T01:01:00Z', 'Line': 'build started'},
              ],
            }),
            '/api/v1/executions/exec-1/artifacts' => _jsonResponse({
              'artifacts': [
                {'Name': 'app.zip', 'Path': '/artifacts/app.zip'},
              ],
            }),
            _ => http.Response('not found', 404),
          };
        }),
      );
      const config = OtoConsoleConfig(
        serverHttpUrl: 'http://core.example.test:18020',
        serverWireUrl: 'ws://core.example.test:18080/runner',
      );

      final runner = await client.fetchRunner(config, 'runner-1');
      final status = await client.fetchRunnerStatus(config, 'runner-1');
      final job = await client.fetchJob(config, 'job-1');
      final execution = await client.fetchExecution(config, 'exec-1');
      final logs = await client.fetchLogs(config, 'exec-1');
      final artifacts = await client.fetchArtifacts(config, 'exec-1');

      expect(runner.state, OtoCoreReadState.data);
      expect(runner.data!.runnerID, 'runner-1');
      expect(status.data!.currentExecutionID, 'exec-1');
      expect(
        job.data!.runRequest!['pipeline_yaml_path'],
        '/pipelines/build.yaml',
      );
      expect(execution.data!.jobID, 'job-1');
      expect(logs.data!.single.line, 'build started');
      expect(artifacts.data!.single.name, 'app.zip');
      expect(requestedPaths, [
        '/api/v1/runners/runner-1',
        '/api/v1/runners/runner-1/status',
        '/api/v1/jobs/job-1',
        '/api/v1/executions/exec-1',
        '/api/v1/executions/exec-1/logs',
        '/api/v1/executions/exec-1/artifacts',
      ]);
    },
  );

  test('OtoHttpCoreReadClient maps empty and error states', () async {
    final client = OtoHttpCoreReadClient(
      httpClient: MockClient((request) async {
        return switch (request.url.path) {
          '/api/v1/runners/missing' => http.Response('not found', 404),
          '/api/v1/jobs/failing' => http.Response('boom', 500),
          '/api/v1/executions/exec-empty/logs' => _jsonResponse({'logs': []}),
          _ => http.Response('not found', 404),
        };
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final missingRunner = await client.fetchRunner(config, 'missing');
    final failingJob = await client.fetchJob(config, 'failing');
    final emptyLogs = await client.fetchLogs(config, 'exec-empty');
    final invalidConfig = await client.fetchExecution(
      const OtoConsoleConfig(
        serverHttpUrl: 'not-a-url',
        serverWireUrl: 'ws://core.example.test:18080/runner',
      ),
      'exec-1',
    );

    expect(missingRunner.state, OtoCoreReadState.empty);
    expect(missingRunner.statusCode, 404);
    expect(failingJob.state, OtoCoreReadState.error);
    expect(failingJob.statusCode, 500);
    expect(emptyLogs.state, OtoCoreReadState.empty);
    expect(emptyLogs.data, isEmpty);
    expect(invalidConfig.state, OtoCoreReadState.error);
    expect(invalidConfig.message, 'Invalid OTO server HTTP URL');
  });

  testWidgets(
    'OtoClientApp wires runners loading empty state when no runnerIDs',
    (tester) async {
      await tester.pumpWidget(
        OtoClientApp(coreClient: _FakeCoreClient(), runnerIDs: const []),
      );
      await tester.pumpAndSettle();

      await tester.tap(find.byTooltip('Runners'));
      await tester.pumpAndSettle();

      expect(find.text('No runners connected'), findsOneWidget);
      expect(find.text('Runner registry is empty.'), findsOneWidget);
    },
  );

  testWidgets('OtoClientApp wires runners error state on API failure', (
    tester,
  ) async {
    final fakeReadClient = _FakeCoreReadClient(
      onFetchRunner: (id) => const OtoCoreReadResult.error(
        'Internal Server Error',
        statusCode: 500,
      ),
      onFetchRunnerStatus: (id) => const OtoCoreReadResult.empty(),
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        runnerIDs: const ['runner-fail'],
      ),
    );
    await tester.pumpAndSettle();

    await tester.tap(find.byTooltip('Runners'));
    await tester.pumpAndSettle();

    expect(find.text('Error loading runners'), findsOneWidget);
    expect(find.text('Internal Server Error'), findsOneWidget);
    expect(fakeReadClient.lastUsedConfig, isNotNull);
  });

  testWidgets('OtoClientApp wires runners data state on API success', (
    tester,
  ) async {
    final fakeReadClient = _FakeCoreReadClient(
      onFetchRunner: (id) => OtoCoreReadResult.data(
        OtoRunnerRecord(
          runnerID: id,
          alias: 'Agent Mini',
          protocolVersion: 'v1',
          status: 'active',
          acceptedAt: DateTime.now(),
          firstHeartbeatAt: DateTime.now(),
          lastHeartbeatAt: DateTime.now(),
          failureReason: '',
        ),
      ),
      onFetchRunnerStatus: (id) => OtoCoreReadResult.data(
        OtoRunnerStatus(
          accepted: true,
          runnerID: id,
          status: 'active',
          currentJobID: 'job-999',
          currentExecutionID: 'exec-888',
          message: 'All good',
        ),
      ),
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        runnerIDs: const ['runner-ok'],
      ),
    );
    await tester.pumpAndSettle();

    await tester.tap(find.byTooltip('Runners'));
    await tester.pumpAndSettle();

    expect(find.text('ID: runner-ok'), findsOneWidget);
    expect(find.text('Alias: Agent Mini'), findsOneWidget);
    expect(find.text('ACTIVE'), findsOneWidget);
    expect(find.text('Message: All good'), findsOneWidget);
    expect(find.textContaining('Job: job-999'), findsOneWidget);
    expect(find.textContaining('Execution: exec-888'), findsOneWidget);
  });

  _registerRunnerSelfUpdateTests();
  _registerExecutionActionTests();
  _registerJobCreateTests();
  _registerJobsExecutionsTests();
}

http.Response _jsonResponse(Map<String, Object?> body) {
  return http.Response(
    jsonEncode(body),
    200,
    headers: {'content-type': 'application/json'},
  );
}

Finder _newItemMenuButton() {
  return find.ancestor(
    of: find.text('New Item'),
    matching: find.byType(InkWell),
  );
}

class _FakeCoreClient implements OtoCoreConnectionClient {
  @override
  Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config) async {
    return const OtoCoreConnectionSnapshot(
      state: OtoCoreConnectionState.online,
      health: OtoCoreEndpointStatus(
        label: 'Health',
        path: '/healthz',
        state: OtoCoreConnectionState.online,
        statusCode: 200,
        message: 'OK',
      ),
      readiness: OtoCoreEndpointStatus(
        label: 'Readiness',
        path: '/readyz',
        state: OtoCoreConnectionState.online,
        statusCode: 200,
        message: 'OK',
      ),
      policyNote: 'CORS headers or a same-origin proxy are required.',
    );
  }
}

class _FakeCoreReadClient implements OtoCoreReadClient {
  final OtoCoreReadResult<OtoRunnerRecord> Function(String runnerID)?
  onFetchRunner;
  final OtoCoreReadResult<OtoRunnerStatus> Function(String runnerID)?
  onFetchRunnerStatus;
  final OtoCoreReadResult<OtoJobRecord> Function(String jobID)? onFetchJob;
  final OtoCoreReadResult<OtoExecutionRecord> Function(String executionID)?
  onFetchExecution;
  final OtoCoreReadResult<List<OtoExecutionLogEntry>> Function(
    String executionID,
  )?
  onFetchLogs;
  final OtoCoreReadResult<List<OtoArtifactRecord>> Function(String executionID)?
  onFetchArtifacts;
  OtoConsoleConfig? lastUsedConfig;

  _FakeCoreReadClient({
    this.onFetchRunner,
    this.onFetchRunnerStatus,
    this.onFetchJob,
    this.onFetchExecution,
    this.onFetchLogs,
    this.onFetchArtifacts,
  });

  @override
  Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
    OtoConsoleConfig config,
    String runnerID,
  ) async {
    lastUsedConfig = config;
    if (onFetchRunner != null) {
      return onFetchRunner!(runnerID);
    }
    return const OtoCoreReadResult.empty();
  }

  @override
  Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
    OtoConsoleConfig config,
    String runnerID,
  ) async {
    lastUsedConfig = config;
    if (onFetchRunnerStatus != null) {
      return onFetchRunnerStatus!(runnerID);
    }
    return const OtoCoreReadResult.empty();
  }

  @override
  Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
    OtoConsoleConfig config,
    String jobID,
  ) async {
    lastUsedConfig = config;
    if (onFetchJob != null) {
      return onFetchJob!(jobID);
    }
    return const OtoCoreReadResult.empty();
  }

  @override
  Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    lastUsedConfig = config;
    if (onFetchExecution != null) {
      return onFetchExecution!(executionID);
    }
    return const OtoCoreReadResult.empty();
  }

  @override
  Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    lastUsedConfig = config;
    if (onFetchLogs != null) {
      return onFetchLogs!(executionID);
    }
    return const OtoCoreReadResult.empty();
  }

  @override
  Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    lastUsedConfig = config;
    if (onFetchArtifacts != null) {
      return onFetchArtifacts!(executionID);
    }
    return const OtoCoreReadResult.empty();
  }
}

class _FakeCoreWriteClient implements OtoCoreWriteClient {
  final Future<OtoCoreWriteResult<OtoJobRecord>> Function(
    OtoConsoleConfig config,
    OtoJobCreateDraft draft,
  )?
  onCreateJob;
  final Future<OtoCoreWriteResult<void>> Function(
    OtoConsoleConfig config,
    OtoExecutionCancelDraft draft,
  )?
  onCancelExecution;
  final Future<OtoCoreWriteResult<void>> Function(
    OtoConsoleConfig config,
    OtoExecutionReportDraft draft,
  )?
  onReportExecution;
  final Future<OtoCoreWriteResult<void>> Function(
    OtoConsoleConfig config,
    OtoExecutionLogDraft draft,
  )?
  onAppendExecutionLog;
  final Future<OtoCoreWriteResult<void>> Function(
    OtoConsoleConfig config,
    OtoExecutionArtifactDraft draft,
  )?
  onAppendExecutionArtifact;
  final Future<OtoCoreWriteResult<void>> Function(
    OtoConsoleConfig config,
    OtoRunnerSelfUpdateDraft draft,
  )?
  onSelfUpdateRunner;
  OtoConsoleConfig? lastUsedConfig;
  OtoJobCreateDraft? lastUsedDraft;

  _FakeCoreWriteClient({
    this.onCreateJob,
    this.onCancelExecution,
    this.onReportExecution,
    this.onAppendExecutionLog,
    this.onAppendExecutionArtifact,
    this.onSelfUpdateRunner,
  });

  @override
  Future<OtoCoreWriteResult<OtoJobRecord>> createJob(
    OtoConsoleConfig config,
    OtoJobCreateDraft draft,
  ) async {
    lastUsedConfig = config;
    lastUsedDraft = draft;
    if (onCreateJob != null) {
      return onCreateJob!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }

  @override
  Future<OtoCoreWriteResult<void>> cancelExecution(
    OtoConsoleConfig config,
    OtoExecutionCancelDraft draft,
  ) async {
    lastUsedConfig = config;
    if (onCancelExecution != null) {
      return onCancelExecution!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }

  @override
  Future<OtoCoreWriteResult<void>> reportExecution(
    OtoConsoleConfig config,
    OtoExecutionReportDraft draft,
  ) async {
    lastUsedConfig = config;
    if (onReportExecution != null) {
      return onReportExecution!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }

  @override
  Future<OtoCoreWriteResult<void>> appendExecutionLog(
    OtoConsoleConfig config,
    OtoExecutionLogDraft draft,
  ) async {
    lastUsedConfig = config;
    if (onAppendExecutionLog != null) {
      return onAppendExecutionLog!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }

  @override
  Future<OtoCoreWriteResult<void>> appendExecutionArtifact(
    OtoConsoleConfig config,
    OtoExecutionArtifactDraft draft,
  ) async {
    lastUsedConfig = config;
    if (onAppendExecutionArtifact != null) {
      return onAppendExecutionArtifact!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }

  @override
  Future<OtoCoreWriteResult<void>> selfUpdateRunner(
    OtoConsoleConfig config,
    OtoRunnerSelfUpdateDraft draft,
  ) async {
    lastUsedConfig = config;
    if (onSelfUpdateRunner != null) {
      return onSelfUpdateRunner!(config, draft);
    }
    return const OtoCoreWriteResult.failed(message: 'No handler');
  }
}

void _registerRunnerSelfUpdateTests() {
  test('OtoHttpCoreWriteClient maps runner self-update responses', () async {
    final requestedPaths = <String>[];
    final requestedBodies = <String>[];
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        requestedPaths.add(request.url.path);
        requestedBodies.add(request.body);
        return switch (request.url.path) {
          '/api/v1/runners/runner-1/self-update' => http.Response(
            jsonEncode({'accepted': true}),
            200,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-deferred/self-update' => http.Response(
            jsonEncode({
              'deferred': true,
              'message': 'Update scheduled for next restart',
            }),
            200,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-error/self-update' => http.Response(
            jsonEncode({'success': false, 'error_message': 'Runner not found'}),
            404,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-bad/self-update' => http.Response(
            jsonEncode({'error_message': 'Invalid version format'}),
            200,
            headers: {'content-type': 'application/json'},
          ),
          _ => http.Response('not found', 404),
        };
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    // --- Accepted ---
    final acceptedDraft = const OtoRunnerSelfUpdateDraft(
      runnerID: 'runner-1',
      version: 'v1.2.3',
      downloadUrl: 'https://releases.example.test/oto-agent',
    );
    var result = await client.selfUpdateRunner(config, acceptedDraft);
    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.message, 'Update accepted');
    expect(requestedPaths, ['/api/v1/runners/runner-1/self-update']);
    final body = jsonDecode(requestedBodies.first) as Map<String, dynamic>;
    expect(body['runner_id'], 'runner-1');
    expect(body['version'], 'v1.2.3');
    expect(body['download_url'], 'https://releases.example.test/oto-agent');

    // --- Deferred ---
    final deferredDraft = const OtoRunnerSelfUpdateDraft(
      runnerID: 'runner-deferred',
      version: 'v1.2.3',
      downloadUrl: 'https://releases.example.test/oto-agent',
    );
    result = await client.selfUpdateRunner(config, deferredDraft);
    expect(result.state, OtoCoreWriteState.deferred);
    expect(result.message, 'Update scheduled for next restart');

    // --- Error (404 with error_message) ---
    final errorDraft = const OtoRunnerSelfUpdateDraft(
      runnerID: 'runner-error',
      version: 'v1.2.3',
      downloadUrl: 'https://releases.example.test/oto-agent',
    );
    result = await client.selfUpdateRunner(config, errorDraft);
    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Runner not found');
    expect(result.statusCode, 404);

    // --- Error (200 + error_message) ---
    final badDraft = const OtoRunnerSelfUpdateDraft(
      runnerID: 'runner-bad',
      version: 'bad',
      downloadUrl: 'https://releases.example.test/oto-agent',
    );
    result = await client.selfUpdateRunner(config, badDraft);
    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Invalid version format');
  });

  testWidgets('OtoClientApp wires runner self-update and refresh', (
    tester,
  ) async {
    final fetchLog = <String>[];

    final fakeWriteClient = _FakeCoreWriteClient(
      onSelfUpdateRunner: (config, draft) async {
        fetchLog.add('selfUpdate:${draft.runnerID}:${draft.version}');
        return const OtoCoreWriteResult<void>.succeeded(
          null,
          message: 'Update accepted',
        );
      },
    );

    final fakeReadClient = _FakeCoreReadClient(
      onFetchRunner: (id) {
        fetchLog.add('fetchRunner:$id');
        return OtoCoreReadResult.data(
          OtoRunnerRecord(
            runnerID: id,
            alias: 'Test Runner',
            protocolVersion: 'v1',
            status: 'idle',
            acceptedAt: DateTime.now(),
            firstHeartbeatAt: DateTime.now(),
            lastHeartbeatAt: DateTime.now(),
            failureReason: '',
          ),
        );
      },
      onFetchRunnerStatus: (id) {
        fetchLog.add('fetchRunnerStatus:$id');
        return OtoCoreReadResult.data(
          OtoRunnerStatus(
            accepted: true,
            runnerID: id,
            status: 'idle',
            currentJobID: '',
            currentExecutionID: '',
            message: '',
          ),
        );
      },
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        runnerIDs: const ['runner-1'],
      ),
    );
    await tester.pumpAndSettle();

    // Navigate to Runners tab
    await tester.tap(find.byTooltip('Runners'));
    await tester.pumpAndSettle();

    expect(find.text('ID: runner-1'), findsOneWidget);
    expect(find.text('Self Update'), findsOneWidget);

    // Clear fetch log after initial load
    fetchLog.clear();

    // Enter version and HTTPS URL
    await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
    await tester.pump();
    await tester.enterText(
      find.byType(TextField).at(1),
      'https://releases.example.test/oto-agent',
    );
    await tester.pump();

    // Tap Update button
    await tester.tap(find.text('Update'));
    await tester.pumpAndSettle();

    // Verify write client was called with correct args
    expect(fetchLog, contains('selfUpdate:runner-1:v2.0.0'));

    // Verify accepted triggers _refreshCoreStatus (fetchRunnerStatus)
    expect(
      fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
      isNotEmpty,
      reason:
          'accepted should trigger _refreshCoreStatus with fetchRunnerStatus',
    );

    // Verify success state is shown
    expect(find.textContaining('Update accepted'), findsOneWidget);
  });

  testWidgets('OtoClientApp runner self-update deferred triggers refresh', (
    tester,
  ) async {
    final fetchLog = <String>[];

    final fakeWriteClient = _FakeCoreWriteClient(
      onSelfUpdateRunner: (config, draft) async {
        fetchLog.add('selfUpdate:${draft.runnerID}');
        return const OtoCoreWriteResult<void>.deferred(
          message: 'Scheduled for next restart',
        );
      },
    );

    final fakeReadClient = _FakeCoreReadClient(
      onFetchRunner: (id) => OtoCoreReadResult.data(
        OtoRunnerRecord(
          runnerID: id,
          alias: '',
          protocolVersion: 'v1',
          status: 'idle',
          acceptedAt: null,
          firstHeartbeatAt: null,
          lastHeartbeatAt: null,
          failureReason: '',
        ),
      ),
      onFetchRunnerStatus: (id) {
        fetchLog.add('fetchRunnerStatus:$id');
        return OtoCoreReadResult.data(
          OtoRunnerStatus(
            accepted: true,
            runnerID: id,
            status: 'idle',
            currentJobID: '',
            currentExecutionID: '',
            message: '',
          ),
        );
      },
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        runnerIDs: const ['runner-1'],
      ),
    );
    await tester.pumpAndSettle();

    await tester.tap(find.byTooltip('Runners'));
    await tester.pumpAndSettle();

    fetchLog.clear();

    await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
    await tester.pump();
    await tester.enterText(
      find.byType(TextField).at(1),
      'https://releases.example.test/oto-agent',
    );
    await tester.pump();
    await tester.tap(find.text('Update'));
    await tester.pumpAndSettle();

    // Deferred also triggers refresh
    expect(
      fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
      isNotEmpty,
      reason: 'deferred should trigger _refreshCoreStatus',
    );
    expect(
      find.textContaining('Deferred: Scheduled for next restart'),
      findsOneWidget,
    );
  });

  testWidgets(
    'OtoClientApp runner self-update failed does not trigger refresh',
    (tester) async {
      final fetchLog = <String>[];

      final fakeWriteClient = _FakeCoreWriteClient(
        onSelfUpdateRunner: (config, draft) async {
          fetchLog.add('selfUpdate');
          return const OtoCoreWriteResult<void>.failed(
            message: 'Runner not found',
          );
        },
      );

      final fakeReadClient = _FakeCoreReadClient(
        onFetchRunner: (id) => OtoCoreReadResult.data(
          OtoRunnerRecord(
            runnerID: id,
            alias: '',
            protocolVersion: 'v1',
            status: 'idle',
            acceptedAt: null,
            firstHeartbeatAt: null,
            lastHeartbeatAt: null,
            failureReason: '',
          ),
        ),
        onFetchRunnerStatus: (id) {
          fetchLog.add('fetchRunnerStatus:$id');
          return const OtoCoreReadResult.empty();
        },
      );

      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: fakeReadClient,
          writeClient: fakeWriteClient,
          runnerIDs: const ['runner-1'],
        ),
      );
      await tester.pumpAndSettle();

      await tester.tap(find.byTooltip('Runners'));
      await tester.pumpAndSettle();

      fetchLog.clear();

      await tester.enterText(find.byType(TextField).at(0), 'v2.0.0');
      await tester.pump();
      await tester.enterText(
        find.byType(TextField).at(1),
        'https://releases.example.test/oto-agent',
      );
      await tester.pump();
      await tester.tap(find.text('Update'));
      await tester.pumpAndSettle();

      // Failed does NOT trigger refresh
      expect(
        fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
        isEmpty,
        reason: 'failed self-update should not trigger _refreshCoreStatus',
      );
      expect(find.textContaining('Runner not found'), findsOneWidget);
    },
  );
}

void _registerExecutionActionTests() {
  testWidgets('OtoHttpCoreWriteClient maps execution action responses', (
    tester,
  ) async {
    final requestedPaths = <String>[];
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        requestedPaths.add(request.url.path);
        return switch (request.url.path) {
          '/api/v1/runners/runner-1/executions/exec-1/cancel' => http.Response(
            jsonEncode({'success': true}),
            200,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-1/executions/exec-1/report' => http.Response(
            jsonEncode({
              'accepted': true,
              'job_id': 'job-1',
              'execution_id': 'exec-1',
              'state': 'succeeded',
              'runner_id': 'runner-1',
            }),
            200,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-1/executions/exec-1/logs' => http.Response(
            jsonEncode({'accepted': true}),
            201,
            headers: {'content-type': 'application/json'},
          ),
          '/api/v1/runners/runner-1/executions/exec-1/artifacts' =>
            http.Response(
              jsonEncode({'accepted': true}),
              201,
              headers: {'content-type': 'application/json'},
            ),
          _ => http.Response('not found', 404),
        };
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    // --- Cancel success ---
    final cancelDraft = const OtoExecutionCancelDraft(
      executionID: 'exec-1',
      runnerID: 'runner-1',
      reason: 'Test cancel',
    );
    var result = await client.cancelExecution(config, cancelDraft);
    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.message, 'cancel accepted');
    expect(requestedPaths, [
      '/api/v1/runners/runner-1/executions/exec-1/cancel',
    ]);

    // --- Report success ---
    final reportDraft = const OtoExecutionReportDraft(
      executionID: 'exec-1',
      runnerID: 'runner-1',
      jobID: 'job-1',
      success: true,
      exitCode: 0,
      message: 'Done',
    );
    result = await client.reportExecution(config, reportDraft);
    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.message, 'report accepted');

    // --- Log success ---
    final logDraft = const OtoExecutionLogDraft(
      executionID: 'exec-1',
      runnerID: 'runner-1',
      line: 'test log line',
    );
    result = await client.appendExecutionLog(config, logDraft);
    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.message, 'log accepted');

    // --- Artifact success ---
    final artifactDraft = const OtoExecutionArtifactDraft(
      executionID: 'exec-1',
      runnerID: 'runner-1',
      name: 'test.txt',
      path: '/tmp/test.txt',
    );
    result = await client.appendExecutionArtifact(config, artifactDraft);
    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.message, 'artifact accepted');

    // --- Cancel without runner id is rejected before hitting a nonexistent Core route ---
    final cancelErrorDraft = const OtoExecutionCancelDraft(
      executionID: 'exec-error',
    );
    result = await client.cancelExecution(config, cancelErrorDraft);
    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Runner ID is required');
    expect(
      requestedPaths,
      isNot(contains('/api/v1/executions/exec-error/cancel')),
    );

    // --- Report without runner id is rejected before hitting a nonexistent Core route ---
    final reportErrorDraft = const OtoExecutionReportDraft(
      executionID: 'exec-bad',
      success: true,
    );
    result = await client.reportExecution(config, reportErrorDraft);
    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Runner ID is required');
    expect(
      requestedPaths,
      isNot(contains('/api/v1/executions/exec-bad/report')),
    );
  });

  testWidgets('OtoClientApp wires execution actions and refresh', (
    tester,
  ) async {
    // Test that execution actions show on expanded execution
    final writeCalls = <String>[];

    final fakeWriteClient = _FakeCoreWriteClient(
      onCancelExecution: (config, draft) async {
        writeCalls.add('cancel:${draft.executionID}');
        return const OtoCoreWriteResult.failed(message: 'cancel rejected');
      },
      onReportExecution: (config, draft) async {
        writeCalls.add('report:${draft.executionID}');
        return const OtoCoreWriteResult.failed(message: 'report rejected');
      },
      onAppendExecutionLog: (config, draft) async {
        writeCalls.add('log:${draft.executionID}');
        return const OtoCoreWriteResult.failed(message: 'log rejected');
      },
      onAppendExecutionArtifact: (config, draft) async {
        writeCalls.add('artifact:${draft.executionID}');
        return const OtoCoreWriteResult.failed(message: 'artifact rejected');
      },
    );

    final fakeReadClient = _FakeCoreReadClient(
      onFetchExecution: (id) => OtoCoreReadResult.data(
        OtoExecutionRecord(
          id: id,
          jobID: 'job-test',
          state: 'running',
          createdAt: DateTime.now(),
          updatedAt: DateTime.now(),
          executionID: id,
        ),
      ),
      onFetchRunnerStatus: (id) => OtoCoreReadResult.data(
        OtoRunnerStatus(
          accepted: true,
          runnerID: id,
          status: 'online',
          currentJobID: 'job-1',
          currentExecutionID: 'exec-action-test',
          message: 'Running',
        ),
      ),
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        runnerIDs: const ['runner-1'],
        executionIDs: const ['exec-action-test'],
      ),
    );
    await tester.pumpAndSettle();

    // Go to Executions tab and expand
    await tester.tap(find.byTooltip('Executions'));
    await tester.pumpAndSettle();
    expect(find.text('Exec ID: exec-action-test'), findsOneWidget);

    await tester.tap(find.text('Exec ID: exec-action-test'));
    await tester.pumpAndSettle();

    // Actions section should be visible
    expect(find.text('Actions'), findsOneWidget);
    expect(find.text('Cancel'), findsOneWidget);
    expect(find.text('Report'), findsOneWidget);
    expect(find.text('Log'), findsOneWidget);
    expect(find.text('Artifact'), findsOneWidget);

    // Tap Report button — should call write client and show failed state
    await tester.tap(find.text('Report'));
    await tester.pumpAndSettle();

    expect(writeCalls, contains('report:exec-action-test'));
    expect(find.textContaining('report rejected'), findsOneWidget);

    // Tap Log and Artifact to verify both write callbacks are wired.
    await tester.tap(find.text('Log'));
    await tester.pumpAndSettle();
    expect(writeCalls, contains('log:exec-action-test'));
    expect(find.textContaining('log rejected'), findsOneWidget);

    await tester.tap(find.text('Artifact'));
    await tester.pumpAndSettle();
    expect(writeCalls, contains('artifact:exec-action-test'));
    expect(find.textContaining('artifact rejected'), findsOneWidget);

    // Failed action does not cause read reloads — only action state shows error
    // (No refresh is triggered on failure)
  });

  testWidgets(
    'OtoClientApp execution action success triggers refresh with real runner id',
    (tester) async {
      final fetchLog = <String>[];

      final fakeWriteClient = _FakeCoreWriteClient(
        onReportExecution: (config, draft) async {
          fetchLog.add('report:${draft.executionID}:runner:${draft.runnerID}');
          // Return success state — use state check, not isSuccess (data is null)
          return OtoCoreWriteResult<void>.succeeded(
            null,
            message: 'report accepted',
          );
        },
      );

      final fakeReadClient = _FakeCoreReadClient(
        onFetchExecution: (id) {
          fetchLog.add('fetchExecution:$id');
          return OtoCoreReadResult.data(
            OtoExecutionRecord(
              id: id,
              jobID: 'job-test',
              state: 'running',
              createdAt: DateTime.now(),
              updatedAt: DateTime.now(),
              executionID: id,
            ),
          );
        },
        onFetchRunnerStatus: (id) {
          fetchLog.add('fetchRunnerStatus:$id');
          return OtoCoreReadResult.data(
            OtoRunnerStatus(
              accepted: true,
              runnerID: id,
              status: 'online',
              currentJobID: 'job-1',
              currentExecutionID: 'exec-action-test',
              message: 'Running',
            ),
          );
        },
        onFetchLogs: (id) {
          fetchLog.add('fetchLogs:$id');
          return const OtoCoreReadResult.data(<OtoExecutionLogEntry>[]);
        },
        onFetchArtifacts: (id) {
          fetchLog.add('fetchArtifacts:$id');
          return const OtoCoreReadResult.data(<OtoArtifactRecord>[]);
        },
      );

      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: fakeReadClient,
          writeClient: fakeWriteClient,
          runnerIDs: const ['runner-1'],
          executionIDs: const ['exec-action-test'],
        ),
      );
      await tester.pumpAndSettle();

      // Go to Executions tab and expand
      await tester.tap(find.byTooltip('Executions'));
      await tester.pumpAndSettle();
      await tester.tap(find.text('Exec ID: exec-action-test'));
      await tester.pumpAndSettle();

      // Clear fetch log after initial load
      fetchLog.clear();

      // Tap Report button — should call write client and show success state
      await tester.tap(find.text('Report'));
      await tester.pumpAndSettle();

      // Verify runner id is real, not execution id
      expect(fetchLog, contains('report:exec-action-test:runner:runner-1'));

      // Verify success triggers _refreshCoreStatus() which includes fetchRunnerStatus
      expect(
        fetchLog.where((e) => e.startsWith('fetchRunnerStatus:')).toList(),
        isNotEmpty,
        reason:
            'success should trigger _refreshCoreStatus() with fetchRunnerStatus',
      );

      // Verify success triggers _loadExecutionDetail(executionID) which includes fetchExecution, fetchLogs, fetchArtifacts
      expect(
        fetchLog.where((e) => e.startsWith('fetchExecution:')).toList(),
        isNotEmpty,
        reason:
            'success should trigger _loadExecutionDetail() with fetchExecution',
      );
      expect(
        fetchLog.where((e) => e.startsWith('fetchLogs:')).toList(),
        isNotEmpty,
        reason: 'success should trigger _loadExecutionDetail() with fetchLogs',
      );
      expect(
        fetchLog.where((e) => e.startsWith('fetchArtifacts:')).toList(),
        isNotEmpty,
        reason:
            'success should trigger _loadExecutionDetail() with fetchArtifacts',
      );
    },
  );

  testWidgets('OtoClientApp unknown runner id keeps action disabled', (
    tester,
  ) async {
    final writeCalls = <String>[];

    final fakeWriteClient = _FakeCoreWriteClient(
      onReportExecution: (config, draft) async {
        writeCalls.add('report:${draft.executionID}:runner:${draft.runnerID}');
        return const OtoCoreWriteResult<void>.succeeded(
          null,
          message: 'report accepted',
        );
      },
    );

    // Runner status has no currentExecutionID matching our execution
    final fakeReadClient = _FakeCoreReadClient(
      onFetchExecution: (id) => OtoCoreReadResult.data(
        OtoExecutionRecord(
          id: id,
          jobID: 'job-test',
          state: 'running',
          createdAt: DateTime.now(),
          updatedAt: DateTime.now(),
          executionID: id,
        ),
      ),
      onFetchRunnerStatus: (id) => OtoCoreReadResult.data(
        OtoRunnerStatus(
          accepted: true,
          runnerID: id,
          status: 'online',
          currentJobID: 'job-other',
          currentExecutionID: 'exec-different',
          message: 'Running',
        ),
      ),
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        runnerIDs: const ['runner-1'],
        executionIDs: const ['exec-no-runner'],
      ),
    );
    await tester.pumpAndSettle();

    // Go to Executions tab and expand
    await tester.tap(find.byTooltip('Executions'));
    await tester.pumpAndSettle();
    await tester.tap(find.text('Exec ID: exec-no-runner'));
    await tester.pumpAndSettle();

    // Actions section should be visible
    expect(find.text('Actions'), findsOneWidget);

    // Tap Report button — even though we tap, the widget should not call write client
    // because runnerID is empty string (disabled)
    await tester.tap(find.text('Report'));
    await tester.pumpAndSettle();

    // When runner id is unknown (empty), the action callback should NOT be called
    // because the UI shows disabled buttons
    expect(
      writeCalls,
      isEmpty,
      reason: 'unknown runner should keep action disabled',
    );
  });
}

void _registerJobCreateTests() {
  testWidgets('OtoHttpCoreWriteClient maps job create responses', (
    tester,
  ) async {
    final requestedPaths = <String>[];
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        requestedPaths.add(request.url.path);
        return switch (request.url.path) {
          '/api/v1/jobs' => http.Response(
            jsonEncode({
              'id': 'new-job-1',
              'name': 'Deploy Pipeline',
              'state': 'queued',
              'created_at': '2026-06-18T01:00:00Z',
              'updated_at': '2026-06-18T01:00:00Z',
              'execution_id': '',
            }),
            201,
            headers: {'content-type': 'application/json'},
          ),
          _ => http.Response('not found', 404),
        };
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(
      id: 'new-job-1',
      name: 'Deploy Pipeline',
    );
    final result = await client.createJob(config, draft);

    expect(result.state, OtoCoreWriteState.succeeded);
    expect(result.data!.id, 'new-job-1');
    expect(result.statusCode, 201);
    expect(requestedPaths, ['/api/v1/jobs']);
  });

  testWidgets('OtoHttpCoreWriteClient maps 400 invalid request', (
    tester,
  ) async {
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        return http.Response(
          '{"error":"bad_request","message":"Job ID is required"}',
          400,
          headers: {'content-type': 'application/json'},
        );
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(id: '', name: '');
    final result = await client.createJob(config, draft);

    expect(result.state, OtoCoreWriteState.failed);
    expect(result.statusCode, 400);
    expect(result.message, 'Job ID is required');
  });

  test('OtoHttpCoreWriteClient maps timeout', () async {
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        await Future<void>.delayed(const Duration(seconds: 30));
        return http.Response('timeout', 504);
      }),
      timeout: const Duration(milliseconds: 100),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job');
    final result = await client.createJob(config, draft);

    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, contains('Timed out'));
  });

  testWidgets('OtoHttpCoreWriteClient maps 409 conflict', (tester) async {
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        return http.Response(
          '{"message": "Job ID already exists"}',
          409,
          headers: {'content-type': 'application/json'},
        );
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(
      id: 'existing-job',
      name: 'Existing Job',
    );
    final result = await client.createJob(config, draft);

    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Job ID already exists');
    expect(result.statusCode, 409);
  });

  testWidgets('OtoHttpCoreWriteClient rejects invalid server URL', (
    tester,
  ) async {
    final client = OtoHttpCoreWriteClient();
    const invalidConfig = OtoConsoleConfig(
      serverHttpUrl: 'not-a-url',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job');
    final result = await client.createJob(invalidConfig, draft);

    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Invalid server URL');
  });

  testWidgets('OtoHttpCoreWriteClient maps 500 server error', (tester) async {
    final client = OtoHttpCoreWriteClient(
      httpClient: MockClient((request) async {
        return http.Response('Internal Server Error', 500);
      }),
    );
    const config = OtoConsoleConfig(
      serverHttpUrl: 'http://core.example.test:18020',
      serverWireUrl: 'ws://core.example.test:18080/runner',
    );

    final draft = const OtoJobCreateDraft(id: 'job-1', name: 'Test Job');
    final result = await client.createJob(config, draft);

    expect(result.state, OtoCoreWriteState.failed);
    expect(result.message, 'Server error');
    expect(result.statusCode, 500);
  });

  testWidgets('OtoClientApp wires job create action and refresh', (
    tester,
  ) async {
    var createCallCount = 0;
    late OtoCoreWriteResult<OtoJobRecord> createResult;

    final createdJobRecord = OtoJobRecord(
      id: 'created-job-1',
      name: 'Created Job',
      state: 'queued',
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
      executionID: '',
      runRequest: null,
    );

    final fakeWriteClient = _FakeCoreWriteClient(
      onCreateJob: (config, draft) async {
        createCallCount++;
        expect(draft.id, 'new-job');
        expect(draft.name, 'New Job');
        return createResult;
      },
    );

    final fakeReadClient = _FakeCoreReadClient(
      onFetchJob: (id) {
        if (id == 'created-job-1') {
          return OtoCoreReadResult.data(createdJobRecord);
        }
        return const OtoCoreReadResult.empty();
      },
    );

    createResult = OtoCoreWriteResult.succeeded(
      createdJobRecord,
      statusCode: 201,
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        jobIDs: const [],
        executionIDs: const [],
      ),
    );
    await tester.pumpAndSettle();

    // Pipelines stays a list surface; New Item owns the create form.
    await tester.tap(find.byTooltip('Pipelines'));
    await tester.pumpAndSettle();
    expect(find.text('No pipeline jobs'), findsOneWidget);
    expect(find.byType(TextField), findsNothing);

    await tester.tap(_newItemMenuButton());
    await tester.pumpAndSettle();
    expect(find.text('New Item section'), findsOneWidget);
    expect(find.text('Pipelines / Jobs'), findsNothing);
    expect(find.text('No pipeline jobs'), findsNothing);
    expect(find.byType(TextField), findsNWidgets(2));
    expect(find.text('Create'), findsOneWidget);

    // Verify fresh form submit button is initially disabled
    var button = tester.widget<ElevatedButton>(
      find.ancestor(
        of: find.text('Create'),
        matching: find.byType(ElevatedButton),
      ),
    );
    expect(button.onPressed, isNull);

    // Fill form and submit
    await tester.enterText(find.byType(TextField).at(0), 'new-job');
    await tester.pump();
    await tester.enterText(find.byType(TextField).at(1), 'New Job');
    await tester.pump();

    // Button should now be enabled
    button = tester.widget<ElevatedButton>(
      find.ancestor(
        of: find.text('Create'),
        matching: find.byType(ElevatedButton),
      ),
    );
    expect(button.onPressed, isNotNull);

    // Tap submit
    await tester.tap(
      find.ancestor(
        of: find.text('Create'),
        matching: find.byType(ElevatedButton),
      ),
    );
    await tester.pumpAndSettle();

    // Verify write client was called once with correct draft
    expect(createCallCount, 1);

    // Verify success state shows message (default message is 'Created')
    // Use find.byType(Container) + text to distinguish from job row "Created Job" and "Created: ..."
    expect(
      find.descendant(
        of: find.byType(Container),
        matching: find.text('Created'),
      ),
      findsOneWidget,
    );

    // Verify refresh fetched the created job
    await tester.tap(find.byTooltip('Executions'));
    await tester.pumpAndSettle();
    await tester.tap(find.byTooltip('Pipelines'));
    await tester.pumpAndSettle();
    expect(find.text('Created Job'), findsOneWidget);
    expect(find.text('Job ID: created-job-1'), findsOneWidget);

    // --- Test failed result shows error message ---
    createCallCount = 0;
    createResult = const OtoCoreWriteResult.failed(message: 'Bad request');

    // Need a fresh New Item surface
    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: _FakeCoreReadClient(),
        writeClient: _FakeCoreWriteClient(
          onCreateJob: (config, draft) async {
            createCallCount++;
            return createResult;
          },
        ),
        jobIDs: const [],
        executionIDs: const [],
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(_newItemMenuButton());
    await tester.pumpAndSettle();

    // Fill and submit again
    await tester.enterText(find.byType(TextField).at(0), 'fail-job');
    await tester.pump();
    await tester.enterText(find.byType(TextField).at(1), 'Fail Job');
    await tester.pump();
    await tester.tap(find.text('Create'));
    await tester.pumpAndSettle();

    expect(createCallCount, 1);
    expect(find.textContaining('Bad request'), findsOneWidget);

    // --- Test duplicate submit guard: submitting state ignores second tap ---
    createCallCount = 0;
    final pendingResult = Completer<OtoCoreWriteResult<OtoJobRecord>>();

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: _FakeCoreReadClient(),
        writeClient: _FakeCoreWriteClient(
          onCreateJob: (config, draft) async {
            createCallCount++;
            return await pendingResult.future;
          },
        ),
        jobIDs: const [],
        executionIDs: const [],
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(_newItemMenuButton());
    await tester.pumpAndSettle();

    await tester.enterText(find.byType(TextField).at(0), 'guard-job');
    await tester.pump();
    await tester.enterText(find.byType(TextField).at(1), 'Guard Job');
    await tester.pump();
    await tester.tap(find.text('Create'));
    await tester.pump();

    // First tap called onCreateJob once
    expect(createCallCount, 1);

    // Tap submit again while submitting — button now shows spinner, not 'Create' text
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    // Second tap was ignored — still only one call
    expect(createCallCount, 1);

    // Resolve the pending result
    pendingResult.complete(
      OtoCoreWriteResult.succeeded(createdJobRecord, statusCode: 201),
    );
    await tester.pumpAndSettle();

    // Should transition to success after resolution
    expect(
      find.descendant(
        of: find.byType(Container),
        matching: find.text('Created'),
      ),
      findsOneWidget,
    );
  });

  testWidgets('OtoClientApp job create callback wires correctly', (
    tester,
  ) async {
    final fakeWriteClient = _FakeCoreWriteClient(
      onCreateJob: (config, draft) async {
        return const OtoCoreWriteResult.failed(message: 'Invalid request body');
      },
    );

    final fakeReadClient = _FakeCoreReadClient();

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        jobIDs: const [],
        executionIDs: const [],
      ),
    );
    await tester.pumpAndSettle();

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

    expect(find.text('New Item section'), findsOneWidget);
    expect(find.text('Pipelines / Jobs'), findsNothing);
    expect(find.byType(TextField), findsNWidgets(2));
  });

  testWidgets('OtoClientApp refresh includes created job IDs', (tester) async {
    final fetchedJobIDs = <String>[];
    final fakeWriteClient = _FakeCoreWriteClient(
      onCreateJob: (config, draft) async {
        return OtoCoreWriteResult.succeeded(
          OtoJobRecord(
            id: draft.id,
            name: draft.name,
            state: 'queued',
            createdAt: DateTime.now(),
            updatedAt: DateTime.now(),
            executionID: '',
            runRequest: null,
          ),
          statusCode: 201,
        );
      },
    );

    final fakeReadClient = _FakeCoreReadClient(
      onFetchJob: (id) {
        fetchedJobIDs.add(id);
        return OtoCoreReadResult.data(
          OtoJobRecord(
            id: id,
            name: 'Job $id',
            state: 'queued',
            createdAt: DateTime.now(),
            updatedAt: DateTime.now(),
            executionID: '',
            runRequest: null,
          ),
        );
      },
    );

    await tester.pumpWidget(
      OtoClientApp(
        coreClient: _FakeCoreClient(),
        readClient: fakeReadClient,
        writeClient: fakeWriteClient,
        jobIDs: const ['existing-job'],
        executionIDs: const [],
      ),
    );
    await tester.pumpAndSettle();

    // Initial refresh should include existing-job
    expect(fetchedJobIDs, ['existing-job']);

    // New Item should have create form visible.
    await tester.tap(_newItemMenuButton());
    await tester.pumpAndSettle();

    expect(find.text('New Item section'), findsOneWidget);
    expect(find.text('Pipelines / Jobs'), findsNothing);
    expect(find.byType(TextField), findsNWidgets(2));
  });
}

void _registerJobsExecutionsTests() {
  testWidgets(
    'OtoClientApp wires jobs, executions and artifacts data state on API success',
    (tester) async {
      final fakeReadClient = _FakeCoreReadClient(
        onFetchJob: (id) => OtoCoreReadResult.data(
          OtoJobRecord(
            id: id,
            name: 'Deploy Pipeline',
            state: 'running',
            createdAt: DateTime.now(),
            updatedAt: DateTime.now(),
            executionID: 'exec-99',
            runRequest: null,
          ),
        ),
        onFetchExecution: (id) => OtoCoreReadResult.data(
          OtoExecutionRecord(
            id: id,
            jobID: 'job-77',
            state: 'succeeded',
            createdAt: DateTime.now(),
            updatedAt: DateTime.now(),
            executionID: id,
          ),
        ),
        onFetchLogs: (id) => const OtoCoreReadResult.data([
          OtoExecutionLogEntry(timestamp: null, line: 'Task 1 done'),
        ]),
        onFetchArtifacts: (id) => const OtoCoreReadResult.data([
          OtoArtifactRecord(
            name: 'release.tar.gz',
            path: '/out/release.tar.gz',
          ),
        ]),
      );

      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: fakeReadClient,
          jobIDs: const ['job-77'],
          executionIDs: const ['exec-99'],
        ),
      );
      await tester.pumpAndSettle();

      // 1. Pipelines / Jobs Tab
      await tester.tap(find.byTooltip('Pipelines'));
      await tester.pumpAndSettle();

      expect(find.text('Deploy Pipeline'), findsOneWidget);
      expect(find.text('RUNNING'), findsOneWidget);
      expect(find.text('Job ID: job-77'), findsOneWidget);

      // 2. Executions Tab
      await tester.tap(find.byTooltip('Executions'));
      await tester.pumpAndSettle();

      expect(find.text('Exec ID: exec-99'), findsOneWidget);
      expect(find.text('SUCCEEDED'), findsOneWidget);
      expect(find.text('Job ID: job-77'), findsOneWidget);

      // Expand to see logs/artifacts
      await tester.tap(find.text('Exec ID: exec-99'));
      await tester.pumpAndSettle();

      expect(find.text('Run exec-99'), findsOneWidget);
      expect(find.text('Stage Graph'), findsOneWidget);
      expect(find.text('Checkout'), findsOneWidget);
      expect(find.text('Build'), findsOneWidget);
      expect(find.text('Package'), findsOneWidget);
      expect(find.text('Publish'), findsOneWidget);
      // 'Console Output' appears once in the run-context task menu and once
      // as the logs pane title.
      expect(find.text('Console Output'), findsNWidgets(2));
      expect(find.textContaining('Task 1 done'), findsOneWidget);
      expect(find.text('release.tar.gz'), findsOneWidget);

      // 3. Artifacts Tab
      await tester.tap(find.byTooltip('Artifacts'));
      await tester.pumpAndSettle();

      expect(find.text('release.tar.gz'), findsOneWidget);
      expect(find.text('Path: /out/release.tar.gz'), findsOneWidget);
    },
  );

  testWidgets(
    'OtoClientApp wires jobs, executions and artifacts empty states when no IDs',
    (tester) async {
      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: _FakeCoreReadClient(),
          jobIDs: const [],
          executionIDs: const [],
        ),
      );
      await tester.pumpAndSettle();

      // Pipelines Tab empty check
      await tester.tap(find.byTooltip('Pipelines'));
      await tester.pumpAndSettle();
      expect(find.text('No pipeline jobs'), findsOneWidget);
      expect(find.text('Job queue is empty.'), findsOneWidget);

      // Executions Tab empty check
      await tester.tap(find.byTooltip('Executions'));
      await tester.pumpAndSettle();
      expect(find.text('No executions'), findsOneWidget);
      expect(find.text('Execution history is empty.'), findsOneWidget);

      // Artifacts Tab empty check
      await tester.tap(find.byTooltip('Artifacts'));
      await tester.pumpAndSettle();
      expect(find.text('No artifacts'), findsOneWidget);
      expect(find.text('Artifact events are empty.'), findsOneWidget);
    },
  );

  testWidgets(
    'OtoClientApp wires jobs, executions and artifacts error states on API failure',
    (tester) async {
      final fakeReadClient = _FakeCoreReadClient(
        onFetchJob: (id) =>
            const OtoCoreReadResult.error('Failed to fetch job 1'),
        onFetchExecution: (id) =>
            const OtoCoreReadResult.error('Failed to fetch exec 1'),
        onFetchArtifacts: (id) =>
            const OtoCoreReadResult.error('Failed to fetch artifacts 1'),
      );

      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: fakeReadClient,
          jobIDs: const ['job-err'],
          executionIDs: const ['exec-err'],
        ),
      );
      await tester.pumpAndSettle();

      // Pipelines Tab error check
      await tester.tap(find.byTooltip('Pipelines'));
      await tester.pumpAndSettle();
      expect(find.text('Error loading jobs'), findsOneWidget);
      expect(find.text('Failed to fetch job 1'), findsOneWidget);

      // Executions Tab error check
      await tester.tap(find.byTooltip('Executions'));
      await tester.pumpAndSettle();
      expect(find.text('Error loading executions'), findsOneWidget);
      expect(find.text('Failed to fetch exec 1'), findsOneWidget);

      // Artifacts Tab error check
      await tester.tap(find.byTooltip('Artifacts'));
      await tester.pumpAndSettle();
      expect(find.text('Error loading artifacts'), findsOneWidget);
      expect(find.text('Failed to fetch artifacts 1'), findsOneWidget);
    },
  );

  testWidgets(
    'OtoClientApp wires expanded execution detail logs and artifacts empty/error states',
    (tester) async {
      final executions = [
        OtoExecutionRecord(
          id: 'exec-detail',
          jobID: 'job-1',
          state: 'running',
          createdAt: DateTime.now(),
          updatedAt: DateTime.now(),
          executionID: 'exec-detail',
        ),
      ];

      bool shouldError = false;

      final fakeReadClient = _FakeCoreReadClient(
        onFetchExecution: (id) => OtoCoreReadResult.data(executions.first),
        onFetchLogs: (id) => shouldError
            ? const OtoCoreReadResult.error('Failed to load logs')
            : const OtoCoreReadResult.empty(data: []),
        onFetchArtifacts: (id) => shouldError
            ? const OtoCoreReadResult.error('Failed to load artifacts')
            : const OtoCoreReadResult.empty(data: []),
      );

      // 1. Render App and Expand to verify Empty State
      await tester.pumpWidget(
        OtoClientApp(
          coreClient: _FakeCoreClient(),
          readClient: fakeReadClient,
          executionIDs: const ['exec-detail'],
        ),
      );
      await tester.pumpAndSettle();

      await tester.tap(find.byTooltip('Executions'));
      await tester.pumpAndSettle();

      // Expand
      await tester.tap(find.text('Exec ID: exec-detail'));
      await tester.pumpAndSettle();

      expect(find.text('Log is empty.'), findsOneWidget);
      expect(find.text('No artifacts produced.'), findsOneWidget);

      // Collapse
      await tester.tap(find.text('Exec ID: exec-detail'));
      await tester.pumpAndSettle();

      // Change to error state
      shouldError = true;

      // Expand again to reload detail in error state
      await tester.tap(find.text('Exec ID: exec-detail'));
      await tester.pumpAndSettle();

      expect(
        find.text('Error loading logs: Failed to load logs'),
        findsOneWidget,
      );
      expect(
        find.text('Error loading artifacts: Failed to load artifacts'),
        findsOneWidget,
      );
    },
  );
}
