import 'dart:async';

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

import 'package:appsok/src/features/builds/builds_page.dart';
import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/services/artifact_staging_service.dart';
import 'package:appsok/src/services/jenkins_client.dart';
import 'package:appsok/src/theme/app_theme.dart';

Widget _wrap(Widget child) => MaterialApp(
  theme: AppTheme.light(),
  home: Scaffold(body: child),
);

List<JenkinsJob> _sampleJobs() => [
  JenkinsJob(
    name: 'android-app',
    fullName: 'team/android-app',
    url: Uri.parse('https://jenkins.example/job/android-app/'),
  ),
  JenkinsJob(
    name: 'ios-build',
    url: Uri.parse('https://jenkins.example/job/ios-build/'),
  ),
];

JenkinsBuild _makeBuild({
  int number = 1,
  String? result = 'SUCCESS',
  String? requestedBy = 'toki',
  String? branch = 'main',
  String? flavor,
  List<BuildArtifact> artifacts = const [],
  DateTime? startedAt,
}) => JenkinsBuild(
  number: number,
  jobName: 'android-app',
  url: Uri.parse('https://jenkins.example/job/android-app/$number/'),
  startedAt: startedAt,
  result: result,
  artifacts: artifacts,
  requestedBy: requestedBy,
  branch: branch,
  flavor: flavor,
);

void _addConnectionStatusTests() {
  group('BuildsPage connection status UI', () {
    testWidgets('BuildsPage renders reauth required state without job loader', (
      tester,
    ) async {
      await tester.pumpWidget(
        _wrap(
          const BuildsPage(
            connectionStatus: JenkinsConnectionStatus.reauthRequired,
          ),
        ),
      );
      expect(find.text('재로그인 필요'), findsOneWidget);
      expect(
        find.byKey(const ValueKey('job-status-action-button')),
        findsNothing,
      );
    });

    testWidgets(
      'BuildsPage renders network retry state and calls retry callback',
      (tester) async {
        var retryCalled = false;
        await tester.pumpWidget(
          _wrap(
            BuildsPage(
              connectionStatus: JenkinsConnectionStatus.networkFailure,
              onRetryConnection: () => retryCalled = true,
            ),
          ),
        );
        expect(find.text('Jenkins 연결 실패'), findsOneWidget);
        expect(
          find.byKey(const ValueKey('job-status-action-button')),
          findsOneWidget,
        );
        await tester.tap(
          find.byKey(const ValueKey('job-status-action-button')),
        );
        expect(retryCalled, isTrue);
      },
    );

    testWidgets('BuildsPage renders server failure retry state', (
      tester,
    ) async {
      var retryCalled = false;
      await tester.pumpWidget(
        _wrap(
          BuildsPage(
            connectionStatus: JenkinsConnectionStatus.serverFailure,
            onRetryConnection: () => retryCalled = true,
          ),
        ),
      );
      expect(find.text('Jenkins 응답 오류'), findsOneWidget);
      expect(
        find.byKey(const ValueKey('job-status-action-button')),
        findsOneWidget,
      );
      await tester.tap(
        find.byKey(const ValueKey('job-status-action-button')),
      );
      expect(retryCalled, isTrue);
    });

    testWidgets(
      'BuildsPage renders missing state without retry button when no callback',
      (tester) async {
        await tester.pumpWidget(
          _wrap(
            const BuildsPage(
              connectionStatus: JenkinsConnectionStatus.missing,
            ),
          ),
        );
        expect(find.text('Jenkins 연결 필요'), findsOneWidget);
        expect(
          find.byKey(const ValueKey('job-status-action-button')),
          findsNothing,
        );
      },
    );
  });
}

void main() {
  _addConnectionStatusTests();

  // ── Job 목록 (기존) ──────────────────────────────────────────────

  testWidgets('renders login required state without job loader', (
    tester,
  ) async {
    await tester.pumpWidget(_wrap(const BuildsPage()));

    expect(find.text('Jenkins 연결 필요'), findsOneWidget);
  });

  testWidgets('renders searchable Jenkins jobs', (tester) async {
    await tester.pumpWidget(
      _wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
    );
    await tester.pumpAndSettle();

    expect(find.text('android-app'), findsOneWidget);
    expect(find.text('ios-build'), findsOneWidget);
    expect(find.byKey(const ValueKey('job-search-field')), findsOneWidget);
  });

  testWidgets('filters jobs by name full name and url', (tester) async {
    await tester.pumpWidget(
      _wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
    );
    await tester.pumpAndSettle();

    await tester.enterText(
      find.byKey(const ValueKey('job-search-field')),
      'android',
    );
    await tester.pump();

    expect(find.text('android-app'), findsOneWidget);
    expect(find.text('ios-build'), findsNothing);
  });

  testWidgets('shows empty state when no jobs are returned', (tester) async {
    await tester.pumpWidget(_wrap(BuildsPage(jobLoader: () async => [])));
    await tester.pumpAndSettle();

    expect(find.text('접근 가능한 job 없음'), findsOneWidget);
  });

  testWidgets('shows permission state for forbidden Jenkins response', (
    tester,
  ) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => throw const JenkinsClientException(
            statusCode: 403,
            message: 'Forbidden',
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('권한 없음'), findsOneWidget);
  });

  testWidgets('shows failure state for network error', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => throw const JenkinsClientException(
            statusCode: 500,
            message: 'Error',
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Jenkins 조회 실패'), findsOneWidget);
  });

  testWidgets('renders job selector without overflow at compact viewport', (
    tester,
  ) async {
    tester.view.physicalSize = const Size(600, 400);
    tester.view.devicePixelRatio = 1.0;
    addTearDown(tester.view.reset);

    await tester.pumpWidget(
      _wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
    );
    await tester.pumpAndSettle();

    expect(tester.takeException(), isNull);
  });

  // ── UI-1: Build Loader State ─────────────────────────────────────

  testWidgets('selects a job and loads builds — placeholder replaced', (
    tester,
  ) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild()],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('build-list-placeholder')), findsNothing);
    expect(find.byKey(const ValueKey('build-search-field')), findsOneWidget);
  });

  testWidgets(
    'shows artifact empty state when job selected without build loader',
    (tester) async {
      await tester.pumpWidget(
        _wrap(BuildsPage(jobLoader: () async => _sampleJobs())),
      );
      await tester.pumpAndSettle();
      await tester.tap(find.text('android-app'));
      await tester.pumpAndSettle();

      expect(find.text('APK artifact 없음'), findsOneWidget);
    },
  );

  testWidgets('reloads builds when back pressed and job reselected', (
    tester,
  ) async {
    var loadCount = 0;
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async {
            loadCount++;
            return [_makeBuild()];
          },
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    expect(loadCount, 1);

    await tester.tap(find.byIcon(Icons.arrow_back));
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    expect(loadCount, 2);
  });

  // ── UI-2: Build Rows, Badges, Filtering ──────────────────────────

  testWidgets('shows requestedBy as primary text in build row', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('alice'), findsOneWidget);
  });

  testWidgets('shows fallback text when requestedBy is null', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(requestedBy: null)],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('알 수 없는 사용자'), findsOneWidget);
  });

  testWidgets('shows success badge for successful build', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(result: 'SUCCESS')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('성공'), findsOneWidget);
  });

  testWidgets('shows failure badge for failed build', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(result: 'FAILURE')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('실패'), findsOneWidget);
  });

  testWidgets('shows running badge for in-progress build', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(result: null)],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('실행 중'), findsOneWidget);
  });

  testWidgets('shows aborted badge for aborted build', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(result: 'ABORTED')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('중단됨'), findsOneWidget);
  });

  testWidgets('shows build number and branch in summary row', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(number: 42, branch: 'release')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.textContaining('#42'), findsOneWidget);
    expect(find.textContaining('release'), findsOneWidget);
  });

  testWidgets('shows apk artifact name in summary row', (tester) async {
    const apk = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/apk/release/app-release.apk',
    );
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(artifacts: const [apk]),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.textContaining('app-release.apk'), findsOneWidget);
  });

  testWidgets('filters builds by requestedBy', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(number: 1, requestedBy: 'alice'),
            _makeBuild(number: 2, requestedBy: 'bob'),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    await tester.enterText(
      find.byKey(const ValueKey('build-search-field')),
      'alice',
    );
    await tester.pump();

    // 'alice'는 TextField 입력값과 row 텍스트 양쪽에 나타나므로 findsWidgets 사용.
    expect(find.text('alice'), findsWidgets);
    expect(find.text('bob'), findsNothing);
  });

  testWidgets('filters builds by build number', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(number: 42, requestedBy: 'alice'),
            _makeBuild(number: 99, requestedBy: 'bob'),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    await tester.enterText(
      find.byKey(const ValueKey('build-search-field')),
      '42',
    );
    await tester.pump();

    expect(find.text('alice'), findsOneWidget);
    expect(find.text('bob'), findsNothing);
  });

  testWidgets('filters builds by artifact file name', (tester) async {
    const apk1 = BuildArtifact(
      fileName: 'app-staging.apk',
      relativePath: 'outputs/app-staging.apk',
    );
    const apk2 = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/app-release.apk',
    );
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(
              number: 1,
              requestedBy: 'alice',
              artifacts: const [apk1],
            ),
            _makeBuild(number: 2, requestedBy: 'bob', artifacts: const [apk2]),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    await tester.enterText(
      find.byKey(const ValueKey('build-search-field')),
      'staging',
    );
    await tester.pump();

    expect(find.text('alice'), findsOneWidget);
    expect(find.text('bob'), findsNothing);
  });

  testWidgets('shows search empty when no builds match query', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [_makeBuild(requestedBy: 'alice')],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    await tester.enterText(
      find.byKey(const ValueKey('build-search-field')),
      'zzznomatch',
    );
    await tester.pump();

    expect(find.text('검색 결과 없음'), findsOneWidget);
  });

  testWidgets('shows install CTA when apk build row is selected', (
    tester,
  ) async {
    const apk = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/app-release.apk',
    );
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(number: 7, artifacts: const [apk]),
          ],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('install-cta')), findsNothing);

    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();

    expect(find.byKey(const ValueKey('install-cta')), findsOneWidget);
    expect(find.text('설치 준비'), findsOneWidget);
    expect(find.text('설치'), findsOneWidget);
    expect(find.textContaining('app-release.apk'), findsWidgets);
  });

  testWidgets('requests install with selected build and apk artifact', (
    tester,
  ) async {
    const apk = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/app-release.apk',
    );
    JenkinsBuild? requestedBuild;
    BuildArtifact? requestedArtifact;

    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(number: 7, artifacts: const [apk]),
          ],
          onInstallRequested: (build, artifact, _) {
            requestedBuild = build;
            requestedArtifact = artifact;
          },
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));

    expect(requestedBuild?.number, 7);
    expect(requestedArtifact?.fileName, 'app-release.apk');
    expect(requestedArtifact?.relativePath, 'outputs/app-release.apk');
  });

  // ── UI-3: Build Empty And Error States ───────────────────────────

  testWidgets('shows build forbidden state for 403', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => throw const JenkinsClientException(
            statusCode: 403,
            message: 'Forbidden',
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('build 조회 권한 없음'), findsOneWidget);
  });

  testWidgets('shows build forbidden state for 401', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => throw const JenkinsClientException(
            statusCode: 401,
            message: 'Unauthorized',
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('build 조회 권한 없음'), findsOneWidget);
  });

  testWidgets('shows build failure state for server error', (tester) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => throw const JenkinsClientException(
            statusCode: 500,
            message: 'Server Error',
          ),
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('build 조회 실패'), findsOneWidget);
  });

  testWidgets('shows artifact empty state for empty build list', (
    tester,
  ) async {
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [],
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();

    expect(find.text('APK artifact 없음'), findsOneWidget);
  });

  // ── UI-4: Download States ─────────────────────────────────────────

  // Converts a raw byte generator into a DownloadTask carrying progress events.
  ArtifactDownloader taskDownloader(
    Stream<List<int>> Function() makeStream, {
    int? totalBytes,
  }) {
    return (build, artifact) {
      var received = 0;
      final events = makeStream().map((chunk) {
        received += chunk.length;
        return DownloadProgressEvent(
          receivedBytes: received,
          totalBytes: totalBytes,
          chunkBytes: chunk,
        );
      });
      // Append isComplete event after all chunks
      final withComplete = events.transform(
        StreamTransformer<
          DownloadProgressEvent,
          DownloadProgressEvent
        >.fromHandlers(
          handleData: (event, sink) => sink.add(event),
          handleDone: (sink) {
            sink.add(
              DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: totalBytes,
                isComplete: true,
              ),
            );
            sink.close();
          },
        ),
      );
      return DownloadTask.fromStream(withComplete);
    };
  }

  Future<void> navigateToCtaWithDownloader(
    WidgetTester tester, {
    required ArtifactDownloader downloader,
    required ArtifactStager stager,
  }) async {
    const apk = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/app-release.apk',
    );
    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(number: 7, artifacts: const [apk]),
          ],
          artifactDownloader: downloader,
          artifactStager: stager,
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();
  }

  testWidgets('shows download progress after install CTA pressed', (
    tester,
  ) async {
    final pauseCompleter = Completer<void>();

    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(512, 1);
        await pauseCompleter.future;
        yield List<int>.filled(512, 2);
      }),
      stager: ({required build, required artifact, required byteStream}) async {
        await byteStream.drain<void>();
        return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pump();
    await tester.pump();

    expect(find.byKey(const ValueKey('download-progress-bar')), findsOneWidget);
    expect(
      find.byKey(const ValueKey('download-cancel-button')),
      findsOneWidget,
    );

    pauseCompleter.complete();
  });

  testWidgets('shows determinate progress label when total bytes are known', (
    tester,
  ) async {
    final pauseCompleter = Completer<void>();

    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(512, 1);
        await pauseCompleter.future;
        yield List<int>.filled(512, 2);
      }, totalBytes: 1024),
      stager: ({required build, required artifact, required byteStream}) async {
        await byteStream.drain<void>();
        return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pump();
    await tester.pump();

    // Determinate label shows "received / total" format
    expect(
      find.byWidgetPredicate(
        (w) =>
            w is Text &&
            (w.key == const ValueKey('download-progress-label')) &&
            (w.data?.contains(' / ') ?? false),
      ),
      findsOneWidget,
    );

    pauseCompleter.complete();
  });

  testWidgets('cancels artifact download and hides partial state', (
    tester,
  ) async {
    final pauseCompleter = Completer<void>();
    final stagerErrorCompleter = Completer<Object>();

    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(256, 1);
        await pauseCompleter.future;
        yield List<int>.filled(256, 2);
      }),
      stager: ({required build, required artifact, required byteStream}) async {
        try {
          await byteStream.drain<void>();
        } catch (e) {
          stagerErrorCompleter.complete(e);
          rethrow;
        }
        return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512);
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pump();
    await tester.pump();

    expect(
      find.byKey(const ValueKey('download-cancel-button')),
      findsOneWidget,
    );

    await tester.tap(find.byKey(const ValueKey('download-cancel-button')));
    await tester.pump();
    pauseCompleter.complete();
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('download-progress-bar')), findsNothing);
    expect(find.text('다운로드 취소됨'), findsOneWidget);
    expect(
      (await stagerErrorCompleter.future).toString(),
      contains('cancelled'),
    );
  });

  testWidgets('shows verified apk state after staging succeeds', (
    tester,
  ) async {
    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(1024, 1);
      }),
      stager: ({required build, required artifact, required byteStream}) async {
        await byteStream.drain<void>();
        return const StagedApk(path: '/tmp/app.apk', sizeBytes: 1024);
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);
    expect(find.text('다운로드 완료'), findsOneWidget);
  });

  testWidgets(
    'install button in verified state passes staged apk to callback',
    (tester) async {
      StagedApk? receivedStaged;
      const apk = BuildArtifact(
        fileName: 'app-release.apk',
        relativePath: 'outputs/app-release.apk',
      );

      await tester.pumpWidget(
        _wrap(
          BuildsPage(
            jobLoader: () async => _sampleJobs(),
            buildLoader: (_) async => [
              _makeBuild(number: 7, artifacts: const [apk]),
            ],
            artifactDownloader: taskDownloader(() async* {
              yield List<int>.filled(1024, 1);
            }),
            artifactStager:
                ({
                  required build,
                  required artifact,
                  required byteStream,
                }) async {
                  await byteStream.drain<void>();
                  return const StagedApk(
                    path: '/tmp/app-release.apk',
                    sizeBytes: 1024,
                  );
                },
            onInstallRequested: (build, artifact, staged) {
              receivedStaged = staged;
            },
          ),
        ),
      );
      await tester.pumpAndSettle();
      await tester.tap(find.text('android-app'));
      await tester.pumpAndSettle();
      await tester.tap(find.byKey(const ValueKey('build-row-7')));
      await tester.pump();

      // Trigger download and wait for verified state
      await tester.tap(find.byKey(const ValueKey('install-cta-button')));
      await tester.pumpAndSettle();

      expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);

      // Install button in verified state must forward StagedApk to callback
      await tester.tap(find.byKey(const ValueKey('install-cta-button')));

      expect(receivedStaged?.path, '/tmp/app-release.apk');
      expect(receivedStaged?.sizeBytes, 1024);
    },
  );

  testWidgets('shows download failure state when staging fails', (
    tester,
  ) async {
    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(512, 1);
      }),
      stager: ({required build, required artifact, required byteStream}) async {
        await byteStream.drain<void>();
        throw Exception('staging failed');
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('download-error-label')), findsOneWidget);
    expect(find.text('다운로드 실패'), findsOneWidget);
  });

  testWidgets('redacts credential-like values in download failure details', (
    tester,
  ) async {
    final apiToken = ['api', 'token', 'value'].join('-');
    final password = ['pass', 'word', 'value'].join('-');

    await navigateToCtaWithDownloader(
      tester,
      downloader: taskDownloader(() async* {
        yield List<int>.filled(512, 1);
      }),
      stager: ({required build, required artifact, required byteStream}) async {
        await byteStream.drain<void>();
        throw JenkinsClientException(
          statusCode: 500,
          message: 'apiToken=$apiToken password="$password"',
        );
      },
    );

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('download-error-label')), findsOneWidget);
    expect(find.text('다운로드 실패'), findsOneWidget);
    expect(find.textContaining('[redacted]'), findsOneWidget);
    expect(find.textContaining(apiToken), findsNothing);
    expect(find.textContaining(password), findsNothing);
  });

  testWidgets(
    'disposes active download as cancellation and does not stage partial apk',
    (tester) async {
      final pauseCompleter = Completer<void>();
      final stagerErrorCompleter = Completer<Object>();

      const apk = BuildArtifact(
        fileName: 'app-release.apk',
        relativePath: 'outputs/app-release.apk',
      );

      await tester.pumpWidget(
        _wrap(
          BuildsPage(
            jobLoader: () async => _sampleJobs(),
            buildLoader: (_) async => [
              _makeBuild(number: 7, artifacts: const [apk]),
            ],
            artifactDownloader: taskDownloader(() async* {
              yield List<int>.filled(256, 1);
              await pauseCompleter.future;
              yield List<int>.filled(256, 2);
            }),
            artifactStager:
                ({
                  required build,
                  required artifact,
                  required byteStream,
                }) async {
                  try {
                    await byteStream.drain<void>();
                  } catch (e) {
                    stagerErrorCompleter.complete(e);
                    rethrow;
                  }
                  return const StagedApk(path: '/tmp/app.apk', sizeBytes: 512);
                },
          ),
        ),
      );
      await tester.pumpAndSettle();
      await tester.tap(find.text('android-app'));
      await tester.pumpAndSettle();
      await tester.tap(find.byKey(const ValueKey('build-row-7')));
      await tester.pump();

      await tester.tap(find.byKey(const ValueKey('install-cta-button')));
      await tester.pump();
      await tester.pump();

      expect(
        find.byKey(const ValueKey('download-progress-bar')),
        findsOneWidget,
      );

      // Navigate back — disposes _InstallCta while download is in progress
      await tester.tap(find.byIcon(Icons.arrow_back));
      await tester.pump();
      pauseCompleter.complete(); // unblock lingering async* generator
      await tester.pumpAndSettle();

      expect(
        (await stagerErrorCompleter.future).toString(),
        contains('cancelled'),
      );
      expect(find.byKey(const ValueKey('verified-label')), findsNothing);
    },
  );

  // ── INSTALL_HANDOFF-1: Verified APK handoff ───────────────────────

  testWidgets('notifies verified apk handoff after download is verified', (
    tester,
  ) async {
    StagedApk? handoffStaged;
    JenkinsBuild? handoffBuild;
    BuildArtifact? handoffArtifact;

    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(
              number: 7,
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
            ),
          ],
          artifactDownloader: taskDownloader(() async* {
            yield List<int>.filled(1024, 1);
          }),
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
          onInstallRequested: (build, artifact, staged) {
            handoffBuild = build;
            handoffArtifact = artifact;
            handoffStaged = staged;
          },
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();

    // Start download
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);

    // Tap install button in verified state — must fire callback with StagedApk
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));

    expect(handoffBuild?.number, 7);
    expect(handoffArtifact?.fileName, 'app-release.apk');
    expect(handoffStaged?.path, '/tmp/staged/app-release.apk');
    expect(handoffStaged?.sizeBytes, 1024);
  });

  testWidgets('disables verified install handoff after first request', (
    tester,
  ) async {
    var handoffCount = 0;

    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(
              number: 7,
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
            ),
          ],
          artifactDownloader: taskDownloader(() async* {
            yield List<int>.filled(1024, 1);
          }),
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
          onInstallRequested: (_, _, _) => handoffCount++,
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pump();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pump();

    expect(handoffCount, 1);
    final button = tester.widget<ElevatedButton>(
      find.byKey(const ValueKey('install-cta-button')),
    );
    expect(button.onPressed, isNull);
  });

  testWidgets('cleans verified staged apk when abandoned before handoff', (
    tester,
  ) async {
    final cleanedPaths = <String>[];

    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(
              number: 7,
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
            ),
          ],
          artifactDownloader: taskDownloader(() async* {
            yield List<int>.filled(1024, 1);
          }),
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
          artifactCleaner: (path) async => cleanedPaths.add(path),
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('verified-label')), findsOneWidget);

    await tester.tap(find.byIcon(Icons.arrow_back));
    await tester.pumpAndSettle();

    expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
  });

  testWidgets('keeps verified staged apk after handoff owns it', (
    tester,
  ) async {
    final cleanedPaths = <String>[];

    await tester.pumpWidget(
      _wrap(
        BuildsPage(
          jobLoader: () async => _sampleJobs(),
          buildLoader: (_) async => [
            _makeBuild(
              number: 7,
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
            ),
          ],
          artifactDownloader: taskDownloader(() async* {
            yield List<int>.filled(1024, 1);
          }),
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
          artifactCleaner: (path) async => cleanedPaths.add(path),
          onInstallRequested: (_, _, _) {},
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app'));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));

    await tester.pumpWidget(_wrap(const SizedBox.shrink()));
    await tester.pumpAndSettle();

    expect(cleanedPaths, isEmpty);
  });

  testWidgets(
    'renders selected job view without overflow at compact viewport',
    (tester) async {
      tester.view.physicalSize = const Size(600, 400);
      tester.view.devicePixelRatio = 1.0;
      addTearDown(tester.view.reset);

      const apk = BuildArtifact(
        fileName: 'very-long-artifact-name-that-might-overflow.apk',
        relativePath:
            'outputs/release/very-long-artifact-name-that-might-overflow.apk',
      );
      await tester.pumpWidget(
        _wrap(
          BuildsPage(
            jobLoader: () async => _sampleJobs(),
            buildLoader: (_) async => [
              _makeBuild(
                requestedBy: 'very-long-user-name-that-could-overflow-the-row',
                branch: 'feature/very-long-branch-name-for-testing',
                flavor: 'productionReleaseFlavor',
                artifacts: const [apk],
              ),
            ],
          ),
        ),
      );
      await tester.pumpAndSettle();
      await tester.tap(find.text('android-app'));
      await tester.pumpAndSettle();

      expect(tester.takeException(), isNull);
    },
  );
}
