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

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

import 'package:appsok/src/app.dart';
import 'package:appsok/src/features/app_shell.dart';
import 'package:appsok/src/models/adb_device.dart';
import 'package:appsok/src/models/install_attempt_result.dart';
import 'package:appsok/src/models/jenkins_build.dart';
import 'package:appsok/src/models/pending_install.dart';
import 'package:appsok/src/features/builds/builds_page.dart'
    show ArtifactCleaner;
import 'package:appsok/src/services/adb_service.dart';
import 'package:appsok/src/services/artifact_staging_service.dart';
import 'package:appsok/src/services/jenkins_artifact_session.dart';
import 'package:appsok/src/services/jenkins_client.dart';
import 'package:appsok/src/services/token_store.dart';
import 'package:appsok/src/theme/app_theme.dart';

class _FakeJenkinsArtifactSession extends JenkinsArtifactSession {
  _FakeJenkinsArtifactSession()
    : super(
        store: TokenStore(),
        client: JenkinsClient(
          client: MockClient((_) async => http.Response('{}', 200)),
        ),
        stager: ArtifactStagingService(),
      );

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

  @override
  Future<bool> restore() async => true;

  @override
  Future<JenkinsSessionRestoreResult> restoreDetailed() async =>
      const JenkinsSessionRestoreResult(
        kind: JenkinsSessionRestoreKind.restored,
      );

  @override
  Future<List<JenkinsJob>> loadJobs() async => [
    JenkinsJob(
      name: 'android-app',
      url: Uri.parse('https://jenkins.example/job/android-app/'),
    ),
  ];

  @override
  Future<List<JenkinsBuild>> loadBuilds(JenkinsJob job) async => [
    JenkinsBuild(
      number: 7,
      jobName: job.name,
      url: Uri.parse('https://jenkins.example/job/android-app/7/'),
      startedAt: null,
      result: 'SUCCESS',
      artifacts: const [_artifact],
      requestedBy: 'toki',
    ),
  ];

  @override
  DownloadTask downloadArtifact(JenkinsBuild build, BuildArtifact artifact) {
    return DownloadTask.fromStream(
      Stream<DownloadProgressEvent>.fromIterable(const [
        DownloadProgressEvent(
          receivedBytes: 4,
          totalBytes: 4,
          chunkBytes: [1, 2, 3, 4],
        ),
        DownloadProgressEvent(
          receivedBytes: 4,
          totalBytes: 4,
          isComplete: true,
        ),
      ]),
    );
  }

  @override
  Future<StagedApk> stageApk({
    required JenkinsBuild build,
    required BuildArtifact artifact,
    required Stream<List<int>> byteStream,
  }) async {
    await byteStream.drain<void>();
    return const StagedApk(path: '/tmp/staged/app-release.apk', sizeBytes: 4);
  }

  @override
  Future<void> cleanup(String path) async {}
}

void main() {
  test('keeps the AppSok work app theme baseline', () {
    final theme = AppTheme.light();
    final colorScheme = theme.colorScheme;

    expect(theme.useMaterial3, isTrue);
    expect(colorScheme.primary, const Color(0xFF0B6E69));
    expect(colorScheme.secondary, const Color(0xFFD66B3D));
    expect(colorScheme.tertiary, const Color(0xFF6655A8));
    expect(theme.scaffoldBackgroundColor, const Color(0xFFFAFBF7));
  });

  test('InstallAttemptResult exposes the current attempt state', () {
    const attempt = InstallAttemptResult(
      pendingInstall: PendingInstall(
        apkPath: '/tmp/staged/app-release.apk',
        fileName: 'app-release.apk',
        buildNumber: 7,
        jobName: 'android-app',
        sizeBytes: 1024,
      ),
      device: AdbDevice(
        serial: 'R5CT90A1B2C',
        state: 'device',
        model: 'SM_S918N',
        product: 'dm3q',
      ),
      result: AdbInstallResult(
        exitCode: 1,
        stdout: 'Performing Streamed Install',
        stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE\nDetailed adb output',
      ),
    );

    expect(attempt.outcome, InstallAttemptOutcome.failure);
    expect(attempt.succeeded, isFalse);
    expect(attempt.failed, isTrue);
    expect(attempt.artifactLabel, '#7 · android-app · app-release.apk');
    expect(attempt.outputSummary, 'INSTALL_FAILED_VERSION_DOWNGRADE');
  });

  test('InstallAttemptResult builds a masked copy report', () {
    const attempt = InstallAttemptResult(
      pendingInstall: PendingInstall(
        apkPath: '/tmp/staged/app-release.apk',
        fileName: 'app-release.apk',
        buildNumber: 7,
        jobName: 'android-app',
        sizeBytes: 1024,
      ),
      device: AdbDevice(
        serial: 'R5CT90A1B2C',
        state: 'device',
        model: 'SM_S918N',
        product: 'dm3q',
      ),
      result: AdbInstallResult(
        exitCode: 1,
        stdout: 'Install from https://jenkins.private/job/app/7/?token=raw',
        stderr:
            'INSTALL_FAILED_VERSION_DOWNGRADE\n'
            'apiToken=secret-token\n'
            'device R5CT90A1B2C',
      ),
    );

    final report = attempt.maskedReport;

    expect(report, contains('AppSok 설치 리포트'));
    expect(report, contains('Build: #7 · android-app · app-release.apk'));
    expect(report, contains('Device: SM S918N (R5*******2C)'));
    expect(report, contains('[masked-url]'));
    expect(report, contains('apiToken=[masked]'));
    expect(report, isNot(contains('jenkins.private')));
    expect(report, isNot(contains('secret-token')));
    expect(report, isNot(contains('R5CT90A1B2C')));
  });

  test('InstallAttemptResult masking tolerates an empty serial', () {
    const attempt = InstallAttemptResult(
      pendingInstall: PendingInstall(
        apkPath: '/tmp/staged/app-release.apk',
        fileName: 'app-release.apk',
        buildNumber: 7,
        jobName: 'android-app',
        sizeBytes: 1024,
      ),
      device: AdbDevice(serial: '', state: 'device', model: 'SM_S918N'),
      result: AdbInstallResult(
        exitCode: 1,
        stdout: 'token=secret-token',
        stderr: 'INSTALL_FAILED',
      ),
    );

    final report = attempt.maskedReport;

    expect(report, contains('Device: SM S918N ([masked-serial])'));
    expect(report, contains('token=[masked]'));
    expect(report, isNot(contains('secret-token')));
  });

  testWidgets('renders the app shell', (WidgetTester tester) async {
    await tester.pumpWidget(const AppSokApp());

    expect(find.text('AppSok'), findsOneWidget);
    expect(find.text('빌드'), findsWidgets);
    expect(find.text('디바이스'), findsOneWidget);
    expect(find.text('콘솔'), findsOneWidget);
    expect(find.text('설정'), findsOneWidget);
  });

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

    await tester.pumpWidget(
      AppSokApp(
        adbDeviceLoader: () async => const [
          AdbDevice(
            serial: 'R5CT90A1B2C',
            state: 'device',
            model: 'SM_S918N',
            product: 'dm3q',
          ),
        ],
      ),
    );

    final navTargets = ['빌드', '디바이스', '콘솔', '설정'];
    for (final label in navTargets) {
      final navLabel = find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text(label),
      );
      await tester.tap(navLabel);
      await tester.pumpAndSettle();
      expect(tester.takeException(), isNull);
    }
  });

  // ── INSTALL_HANDOFF-2: Shell handoff routing ─────────────────────

  testWidgets('routes verified apk handoff to devices page', (tester) async {
    const apk = BuildArtifact(
      fileName: 'app-release.apk',
      relativePath: 'outputs/app-release.apk',
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          jobLoader: () async => [
            JenkinsJob(
              name: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/'),
            ),
          ],
          buildLoader: (_) async => [
            JenkinsBuild(
              number: 7,
              jobName: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/7/'),
              startedAt: null,
              result: 'SUCCESS',
              artifacts: const [apk],
              requestedBy: 'toki',
            ),
          ],
          artifactDownloader: (build, artifact) {
            var received = 0;
            final events = (() async* {
              final chunk = List<int>.filled(1024, 1);
              received += chunk.length;
              yield DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: 1024,
                chunkBytes: chunk,
                isComplete: true,
              );
            })();
            return DownloadTask.fromStream(events);
          },
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
        ),
      ),
    );
    await tester.pumpAndSettle();

    // Navigate to builds, select job, select build
    await tester.tap(find.text('android-app').first);
    await tester.pumpAndSettle();
    await tester.tap(find.byKey(const ValueKey('build-row-7')));
    await tester.pump();

    // Start 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);

    // Tap install in verified state — shell should switch to devices page
    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();

    // Devices page is now active and shows pending install banner
    expect(
      find.byKey(const ValueKey('pending-install-banner')),
      findsOneWidget,
    );
    expect(
      find.byKey(const ValueKey('pending-install-filename')),
      findsOneWidget,
    );
    expect(find.text('app-release.apk'), findsOneWidget);
  });

  // ── INSTALL_HANDOFF-3: Device page pending install UI ────────────

  testWidgets('shows pending apk install summary on devices page', (
    tester,
  ) async {
    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          jobLoader: () async => [
            JenkinsJob(
              name: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/'),
            ),
          ],
          buildLoader: (_) async => [
            JenkinsBuild(
              number: 7,
              jobName: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/7/'),
              startedAt: null,
              result: 'SUCCESS',
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
              requestedBy: 'toki',
            ),
          ],
          artifactDownloader: (build, artifact) {
            var received = 0;
            final events = (() async* {
              final chunk = List<int>.filled(2 * 1024 * 1024, 1);
              received += chunk.length;
              yield DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: 2 * 1024 * 1024,
                chunkBytes: chunk,
                isComplete: true,
              );
            })();
            return DownloadTask.fromStream(events);
          },
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 2 * 1024 * 1024,
                );
              },
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app').first);
    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.pumpAndSettle();

    expect(
      find.byKey(const ValueKey('pending-install-banner')),
      findsOneWidget,
    );
    expect(find.text('app-release.apk'), findsOneWidget);
    // Size label should show MB
    expect(find.textContaining('MB'), findsOneWidget);
    // Local APK path must be visible
    expect(find.byKey(const ValueKey('pending-install-path')), findsOneWidget);
    expect(find.text('/tmp/staged/app-release.apk'), findsOneWidget);
  });

  testWidgets('cancels pending install and cleans staged apk', (tester) async {
    final cleanedPaths = <String>[];

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          jobLoader: () async => [
            JenkinsJob(
              name: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/'),
            ),
          ],
          buildLoader: (_) async => [
            JenkinsBuild(
              number: 7,
              jobName: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/7/'),
              startedAt: null,
              result: 'SUCCESS',
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
              requestedBy: 'toki',
            ),
          ],
          artifactDownloader: (build, artifact) {
            var received = 0;
            final events = (() async* {
              final chunk = List<int>.filled(1024, 1);
              received += chunk.length;
              yield DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: 1024,
                chunkBytes: chunk,
                isComplete: true,
              );
            })();
            return DownloadTask.fromStream(events);
          },
          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').first);
    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.pumpAndSettle();

    expect(
      find.byKey(const ValueKey('pending-install-banner')),
      findsOneWidget,
    );

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

    expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
    expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
  });

  testWidgets('keeps install button disabled when shell has no installer', (
    tester,
  ) async {
    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          jobLoader: () async => [
            JenkinsJob(
              name: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/'),
            ),
          ],
          buildLoader: (_) async => [
            JenkinsBuild(
              number: 7,
              jobName: 'android-app',
              url: Uri.parse('https://jenkins.example/job/android-app/7/'),
              startedAt: null,
              result: 'SUCCESS',
              artifacts: const [
                BuildArtifact(
                  fileName: 'app-release.apk',
                  relativePath: 'outputs/app-release.apk',
                ),
              ],
              requestedBy: 'toki',
            ),
          ],
          artifactDownloader: (build, artifact) {
            var received = 0;
            final events = (() async* {
              final chunk = List<int>.filled(1024, 1);
              received += chunk.length;
              yield DownloadProgressEvent(
                receivedBytes: received,
                totalBytes: 1024,
                chunkBytes: chunk,
                isComplete: true,
              );
            })();
            return DownloadTask.fromStream(events);
          },
          artifactStager:
              ({required build, required artifact, required byteStream}) async {
                await byteStream.drain<void>();
                return const StagedApk(
                  path: '/tmp/staged/app-release.apk',
                  sizeBytes: 1024,
                );
              },
          // No installer — install buttons must be disabled
        ),
      ),
    );
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app').first);
    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.pumpAndSettle();

    expect(
      find.byKey(const ValueKey('pending-install-banner')),
      findsOneWidget,
    );

    final installButtons = tester.widgetList<IconButton>(
      find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
    );
    for (final btn in installButtons) {
      expect(btn.onPressed, isNull);
    }
  });

  // ── UILC: Install lifecycle regression ──────────────────────────

  Future<void> navigateToDevicesWithPending(WidgetTester tester) async {
    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app').first);
    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.pumpAndSettle();
  }

  Widget shellWithInstaller({
    required Future<AdbInstallResult> Function(AdbDevice, PendingInstall)
    installerFn,
    ArtifactCleaner? artifactCleaner,
  }) {
    return MaterialApp(
      theme: AppTheme.light(),
      home: AppSokShell(
        jobLoader: () async => [
          JenkinsJob(
            name: 'android-app',
            url: Uri.parse('https://jenkins.example/job/android-app/'),
          ),
        ],
        buildLoader: (_) async => [
          JenkinsBuild(
            number: 7,
            jobName: 'android-app',
            url: Uri.parse('https://jenkins.example/job/android-app/7/'),
            startedAt: null,
            result: 'SUCCESS',
            artifacts: const [
              BuildArtifact(
                fileName: 'app-release.apk',
                relativePath: 'outputs/app-release.apk',
              ),
            ],
            requestedBy: 'toki',
          ),
        ],
        artifactDownloader: (build, artifact) {
          var received = 0;
          final events = (() async* {
            final chunk = List<int>.filled(1024, 1);
            received += chunk.length;
            yield DownloadProgressEvent(
              receivedBytes: received,
              totalBytes: 1024,
              chunkBytes: chunk,
              isComplete: true,
            );
          })();
          return DownloadTask.fromStream(events);
        },
        artifactStager:
            ({required build, required artifact, required byteStream}) async {
              await byteStream.drain<void>();
              return const StagedApk(
                path: '/tmp/staged/app-release.apk',
                sizeBytes: 1024,
              );
            },
        installer: installerFn,
        artifactCleaner: artifactCleaner,
      ),
    );
  }

  testWidgets('non-ready device keeps install button disabled', (tester) async {
    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async =>
            const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
      ),
    );
    await navigateToDevicesWithPending(tester);

    // ZX1G22K7Q9 is unauthorized — its install button must be disabled
    final allInstallButtons = tester
        .widgetList<IconButton>(
          find.byWidgetPredicate((w) => w is IconButton && w.tooltip == '설치'),
        )
        .toList();
    // 3 devices: 2 ready (enabled), 1 unauthorized (disabled)
    expect(allInstallButtons.where((b) => b.onPressed == null), hasLength(1));
  });

  testWidgets(
    'loaded device states distinguish ready, unauthorized, and offline',
    (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          theme: AppTheme.light(),
          home: AppSokShell(
            deviceLoader: () async => const [
              AdbDevice(
                serial: 'R5CT90A1B2C',
                state: 'device',
                model: 'SM_S918N',
                product: 'dm3q',
                device: 'dm3q',
              ),
              AdbDevice(
                serial: 'ZX1G22K7Q9',
                state: 'unauthorized',
                model: 'Pixel_8',
                product: 'shiba',
                device: 'shiba',
              ),
              AdbDevice(
                serial: 'emulator-5554',
                state: 'offline',
                model: 'sdk_gphone64_arm64',
                product: 'emu64a',
                device: 'emu64a',
              ),
            ],
          ),
        ),
      );

      await tester.tap(
        find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text('디바이스'),
        ),
      );
      await tester.pumpAndSettle();

      expect(
        find.byKey(const ValueKey('device-state-R5CT90A1B2C')),
        findsOneWidget,
      );
      expect(find.text('ready'), findsOneWidget);
      expect(
        find.byKey(const ValueKey('device-state-ZX1G22K7Q9')),
        findsOneWidget,
      );
      expect(find.text('승인 필요'), findsOneWidget);
      expect(find.text('폰에서 USB debugging 승인 필요'), findsOneWidget);
      expect(
        find.byKey(const ValueKey('device-state-emulator-5554')),
        findsOneWidget,
      );
      expect(find.text('offline'), findsOneWidget);
      expect(find.text('ADB 연결 대기'), findsOneWidget);
    },
  );

  testWidgets('refresh button reloads the adb device list', (tester) async {
    var calls = 0;

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          deviceLoader: () async {
            calls += 1;
            if (calls == 1) {
              return const [
                AdbDevice(
                  serial: 'first-device',
                  state: 'device',
                  model: 'Pixel_8',
                  product: 'shiba',
                ),
              ];
            }
            return const [
              AdbDevice(
                serial: 'second-device',
                state: 'offline',
                model: 'SM_S918N',
                product: 'dm3q',
              ),
            ];
          },
        ),
      ),
    );

    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('디바이스'),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Pixel 8'), findsOneWidget);
    expect(find.text('SM S918N'), findsNothing);

    await tester.tap(find.byKey(const ValueKey('device-refresh-button')));
    await tester.pumpAndSettle();

    expect(calls, 2);
    expect(find.text('Pixel 8'), findsNothing);
    expect(find.text('SM S918N'), findsOneWidget);
    expect(find.text('offline'), findsOneWidget);
  });

  testWidgets('ready device runs installer and shows success output', (
    tester,
  ) async {
    final cleanedPaths = <String>[];
    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async => const AdbInstallResult(
          exitCode: 0,
          stdout: 'Performing Streamed Install\nSuccess',
          stderr: '',
        ),
        artifactCleaner: (path) async => cleanedPaths.add(path),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
    expect(
      find.textContaining('#7 · android-app · app-release.apk'),
      findsOneWidget,
    );
    expect(find.byKey(const ValueKey('install-result-output')), findsOneWidget);
    expect(find.textContaining('Success'), findsWidgets);
  });

  testWidgets('cleans staged apk once after successful install result', (
    tester,
  ) async {
    final cleanedPaths = <String>[];
    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async =>
            const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
        artifactCleaner: (path) async => cleanedPaths.add(path),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
    // Result banner must survive after cleanup
    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
    expect(
      find.byKey(const ValueKey('current-install-result-title')),
      findsOneWidget,
    );
    expect(find.text('현재 설치 결과'), findsOneWidget);
    // Pending banner must be gone
    expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
    expect(find.textContaining('최근 설치'), findsNothing);
  });

  testWidgets(
    'cleans staged apk once after failed install result while keeping stderr visible',
    (tester) async {
      final cleanedPaths = <String>[];
      await tester.pumpWidget(
        shellWithInstaller(
          installerFn: (device, pending) async => const AdbInstallResult(
            exitCode: 1,
            stdout: '',
            stderr: 'INSTALL_FAILED_VERSION_DOWNGRADE',
          ),
          artifactCleaner: (path) async => cleanedPaths.add(path),
        ),
      );
      await navigateToDevicesWithPending(tester);

      final installButtons = find.byWidgetPredicate(
        (w) => w is IconButton && w.tooltip == '설치',
      );
      await tester.tap(installButtons.first);
      await tester.pumpAndSettle();

      expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
      expect(
        find.textContaining('#7 · android-app · app-release.apk'),
        findsOneWidget,
      );
      expect(
        find.byKey(const ValueKey('install-result-banner')),
        findsOneWidget,
      );
      expect(
        find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'),
        findsOneWidget,
      );
    },
  );

  testWidgets('shows concise failure summary in result banner', (tester) async {
    final cleanedPaths = <String>[];
    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async => const AdbInstallResult(
          exitCode: 1,
          stdout: 'Performing Streamed Install',
          stderr:
              'INSTALL_FAILED_VERSION_DOWNGRADE\nDetailed adb output\nExtra line',
        ),
        artifactCleaner: (path) async => cleanedPaths.add(path),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
    expect(
      find.byKey(const ValueKey('install-result-metadata')),
      findsOneWidget,
    );
    expect(
      find.textContaining('#7 · android-app · app-release.apk'),
      findsOneWidget,
    );
    expect(
      find.textContaining('INSTALL_FAILED_VERSION_DOWNGRADE'),
      findsOneWidget,
    );
    expect(find.textContaining('Detailed adb output'), findsNothing);
  });

  testWidgets('copies masked install report from current result banner', (
    tester,
  ) async {
    String? copiedText;
    tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
      SystemChannels.platform,
      (call) async {
        if (call.method == 'Clipboard.setData') {
          final args = Map<Object?, Object?>.from(call.arguments as Map);
          copiedText = args['text'] as String?;
        }
        return null;
      },
    );
    addTearDown(
      () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
        SystemChannels.platform,
        null,
      ),
    );

    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async => const AdbInstallResult(
          exitCode: 1,
          stdout: 'downloaded from https://jenkins.private/job/app/7/',
          stderr:
              'INSTALL_FAILED_VERSION_DOWNGRADE\n'
              'token=secret-token\n'
              'serial R5CT90A1B2C',
        ),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    await tester.tap(find.byKey(const ValueKey('install-report-copy-button')));
    await tester.pump();
    await tester.pump(const Duration(milliseconds: 100));

    expect(copiedText, isNotNull);
    expect(copiedText, contains('AppSok 설치 리포트'));
    expect(copiedText, contains('INSTALL_FAILED_VERSION_DOWNGRADE'));
    expect(copiedText, contains('[masked-url]'));
    expect(copiedText, contains('token=[masked]'));
    expect(copiedText, isNot(contains('jenkins.private')));
    expect(copiedText, isNot(contains('secret-token')));
    expect(copiedText, isNot(contains('R5CT90A1B2C')));
    expect(find.text('리포트 복사됨'), findsOneWidget);
  });

  testWidgets('clears only the current install result from devices page', (
    tester,
  ) async {
    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async =>
            const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);

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

    expect(find.byKey(const ValueKey('install-result-banner')), findsNothing);
    expect(find.byKey(const ValueKey('pending-install-banner')), findsNothing);
    expect(find.textContaining('최근 설치'), findsNothing);
  });

  testWidgets('does not clean staged apk before install future completes', (
    tester,
  ) async {
    final cleanedPaths = <String>[];
    final completer = Completer<AdbInstallResult>();

    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) => completer.future,
        artifactCleaner: (path) async => cleanedPaths.add(path),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pump(); // start install, do not settle

    // Cleanup must NOT have been called yet
    expect(cleanedPaths, isEmpty);

    completer.complete(
      const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
    );
    await tester.pumpAndSettle();

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

  testWidgets(
    'keeps device page compact viewport free of overflow with pending apk',
    (tester) async {
      tester.view.physicalSize = const Size(600, 400);
      tester.view.devicePixelRatio = 1.0;
      addTearDown(tester.view.reset);

      await tester.pumpWidget(
        MaterialApp(
          theme: AppTheme.light(),
          home: AppSokShell(
            jobLoader: () async => [
              JenkinsJob(
                name: 'android-app',
                url: Uri.parse('https://jenkins.example/job/android-app/'),
              ),
            ],
            buildLoader: (_) async => [
              JenkinsBuild(
                number: 7,
                jobName: 'android-app',
                url: Uri.parse('https://jenkins.example/job/android-app/7/'),
                startedAt: null,
                result: 'SUCCESS',
                artifacts: const [
                  BuildArtifact(
                    fileName: 'very-long-artifact-name-for-overflow-test.apk',
                    relativePath:
                        'outputs/very-long-artifact-name-for-overflow-test.apk',
                  ),
                ],
                requestedBy: 'toki',
              ),
            ],
            artifactDownloader: (build, artifact) {
              var received = 0;
              final events = (() async* {
                final chunk = List<int>.filled(1024, 1);
                received += chunk.length;
                yield DownloadProgressEvent(
                  receivedBytes: received,
                  totalBytes: 1024,
                  chunkBytes: chunk,
                  isComplete: true,
                );
              })();
              return DownloadTask.fromStream(events);
            },
            artifactStager:
                ({
                  required build,
                  required artifact,
                  required byteStream,
                }) async {
                  await byteStream.drain<void>();
                  return const StagedApk(
                    path: '/tmp/staged/app.apk',
                    sizeBytes: 1024,
                  );
                },
          ),
        ),
      );
      await tester.pumpAndSettle();
      await tester.tap(find.text('android-app').first);
      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.pumpAndSettle();

      expect(
        find.byKey(const ValueKey('pending-install-banner')),
        findsOneWidget,
      );
      expect(tester.takeException(), isNull);
    },
  );

  // ── REVIEW_UILC: lifecycle edge regression ──────────────────────

  testWidgets(
    'cleans staged apk when install completes after leaving devices page',
    (tester) async {
      final cleanedPaths = <String>[];
      final completer = Completer<AdbInstallResult>();

      await tester.pumpWidget(
        shellWithInstaller(
          installerFn: (device, pending) => completer.future,
          artifactCleaner: (path) async => cleanedPaths.add(path),
        ),
      );
      await navigateToDevicesWithPending(tester);

      // Tap install on first ready device
      final installButtons = find.byWidgetPredicate(
        (w) => w is IconButton && w.tooltip == '설치',
      );
      await tester.tap(installButtons.first);
      await tester.pump(); // start install, future pending

      // Navigate away from devices page while install is in flight
      final buildsNav = find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('빌드'),
      );
      await tester.tap(buildsNav);
      await tester.pump();

      // Complete the install future after DevicesPage is no longer visible
      completer.complete(
        const AdbInstallResult(exitCode: 0, stdout: 'Success', stderr: ''),
      );
      await tester.pumpAndSettle();

      // Cleanup must have been called exactly once — no Flutter exception
      expect(tester.takeException(), isNull);
      expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
    },
  );

  testWidgets('shows failure and cleans staged apk when installer throws', (
    tester,
  ) async {
    final cleanedPaths = <String>[];

    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async => throw Exception('adb missing'),
        artifactCleaner: (path) async => cleanedPaths.add(path),
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    // No Flutter exception propagated
    expect(tester.takeException(), isNull);
    // Cleanup called once
    expect(cleanedPaths, ['/tmp/staged/app-release.apk']);
    // Failure result banner shown with exception text
    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
    expect(find.textContaining('adb missing'), findsOneWidget);
    // Install buttons no longer stuck — hourglass_top icon must not appear
    expect(
      find.byWidgetPredicate((w) => w is Icon && w.icon == Icons.hourglass_top),
      findsNothing,
    );
  });

  testWidgets('keeps install attempt metadata with install result banner', (
    tester,
  ) async {
    const fileName = 'app-release.apk';
    const jobName = 'android-app';
    const buildNumber = 7;

    await tester.pumpWidget(
      shellWithInstaller(
        installerFn: (device, pending) async {
          expect(pending.fileName, fileName);
          expect(pending.jobName, jobName);
          expect(pending.buildNumber, buildNumber);
          return const AdbInstallResult(
            exitCode: 0,
            stdout: 'Success',
            stderr: '',
          );
        },
      ),
    );
    await navigateToDevicesWithPending(tester);

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    final attemptKey = const ValueKey(
      'install-result-attempt:$jobName:$buildNumber:$fileName',
    );
    expect(find.byKey(attemptKey), findsOneWidget);
    expect(find.textContaining('SM S918N에 설치 성공'), findsOneWidget);
    expect(
      find.textContaining('#7 · android-app · app-release.apk'),
      findsOneWidget,
    );
  });

  testWidgets(
    'switches between all shell destinations with status placeholders',
    (WidgetTester tester) async {
      await tester.pumpWidget(
        AppSokApp(
          adbDeviceLoader: () async => const [
            AdbDevice(
              serial: 'device-ready-1',
              state: 'device',
              model: 'Pixel_8',
              product: 'shiba',
            ),
          ],
        ),
      );

      final navTargets = <Map<String, String>>[
        {'label': '빌드', 'marker': 'Jenkins 연결 필요'},
        {'label': '디바이스', 'marker': 'Pixel 8'},
        {'label': '콘솔', 'marker': '패키지, 태그 또는 텍스트'},
        {'label': '설정', 'marker': 'Base URL'},
      ];

      for (final target in navTargets) {
        final navLabel = find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text(target['label']!),
        );

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

        expect(find.byKey(const ValueKey('status-jenkins')), findsOneWidget);
        expect(find.byKey(const ValueKey('status-adb')), findsOneWidget);
        expect(find.text('Jenkins 대기'), findsOneWidget);
        expect(find.text('ADB 대기'), findsOneWidget);
        expect(find.text(target['marker']!), findsOneWidget);
      }
    },
  );

  // ── AppSokApp stored-session wiring ──────────────────────────────

  testWidgets('AppSokApp wires stored Jenkins session into builds page', (
    tester,
  ) async {
    final requestedPaths = <String>[];

    FlutterSecureStorage.setMockInitialValues({
      'jenkins.baseUrl': 'https://jenkins.example',
      'jenkins.username': 'user',
      'jenkins.apiToken': 'token',
    });

    await tester.pumpWidget(
      AppSokApp(
        jenkinsClient: JenkinsClient(
          client: MockClient((request) async {
            if (request.url.path.endsWith('whoAmI/api/json')) {
              requestedPaths.add('whoAmI');
              return http.Response(jsonEncode({'id': 'user'}), 200);
            }
            if (request.url.path.endsWith('/api/json')) {
              requestedPaths.add('jobs');
              return http.Response(
                jsonEncode({
                  'jobs': [
                    {
                      'name': 'sample-job',
                      'fullName': 'sample-job',
                      'url': 'https://jenkins.example/job/sample-job/',
                    },
                  ],
                }),
                200,
              );
            }
            return http.Response(jsonEncode({}), 200);
          }),
        ),
      ),
    );

    await tester.pumpAndSettle();

    expect(requestedPaths, containsAllInOrder(<String>['whoAmI', 'jobs']));
    expect(find.text('Jenkins 연결 필요'), findsNothing);
    expect(find.text('sample-job'), findsOneWidget);
  });

  testWidgets('AppSokApp wires staged apk into adb installer', (tester) async {
    final installCalls = <({String serial, String apkPath})>[];

    await tester.pumpWidget(
      AppSokApp(
        session: _FakeJenkinsArtifactSession(),
        adbDeviceLoader: () async => const [
          AdbDevice(
            serial: 'R5CT90A1B2C',
            state: 'device',
            model: 'SM_S918N',
            product: 'dm3q',
          ),
        ],
        adbInstaller: (device, pending) async {
          installCalls.add((serial: device.serial, apkPath: pending.apkPath));
          return const AdbInstallResult(
            exitCode: 0,
            stdout: 'Success',
            stderr: '',
          );
        },
      ),
    );

    await tester.pumpAndSettle();
    await tester.tap(find.text('android-app').first);
    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();

    final verifiedLabel = find.byKey(const ValueKey('verified-label'));
    for (var i = 0; i < 20 && verifiedLabel.evaluate().isEmpty; i += 1) {
      await tester.pump(const Duration(milliseconds: 20));
    }
    await tester.pumpAndSettle();
    expect(verifiedLabel, findsOneWidget);

    await tester.tap(find.byKey(const ValueKey('install-cta-button')));
    await tester.pumpAndSettle();
    expect(
      find.byKey(const ValueKey('pending-install-banner')),
      findsOneWidget,
    );

    final installButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == '설치',
    );
    await tester.tap(installButtons.first);
    await tester.pumpAndSettle();

    expect(installCalls, hasLength(1));
    expect(installCalls.single.serial, 'R5CT90A1B2C');
    expect(installCalls.single.apkPath, '/tmp/staged/app-release.apk');
    expect(find.byKey(const ValueKey('install-result-banner')), findsOneWidget);
    expect(find.textContaining('Success'), findsWidgets);
  });

  testWidgets(
    'AppSokApp keeps Jenkins connection required state when no stored session exists',
    (tester) async {
      FlutterSecureStorage.setMockInitialValues({});

      await tester.pumpWidget(const AppSokApp());

      await tester.pumpAndSettle();

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

  testWidgets('AppSokApp does not expose token text on auth failure', (
    tester,
  ) async {
    const secretToken = 's3cr3t-api-t0k3n';
    FlutterSecureStorage.setMockInitialValues({
      'jenkins.baseUrl': 'https://jenkins.example',
      'jenkins.username': 'user',
      'jenkins.apiToken': secretToken,
    });

    await tester.pumpWidget(
      AppSokApp(
        jenkinsClient: JenkinsClient(
          client: MockClient((_) async => http.Response('Unauthorized', 401)),
        ),
      ),
    );

    await tester.pumpAndSettle();

    expect(find.textContaining(secretToken), findsNothing);
    expect(find.text('재로그인 필요'), findsOneWidget);
  });

  // ── ADB diagnostics display ──────────────────────────────────────

  testWidgets('SettingsPage renders ADB diagnostics from fake loader', (
    tester,
  ) async {
    final fakeRuntime = AdbRuntime(
      executablePath: '/opt/appsok/adb',
      source: AdbExecutableSource.bundled,
      serverPort: 5038,
      bundledExists: true,
      fallbackUsed: false,
    );
    final fakeDiag = AdbRuntimeDiagnostics(
      runtime: fakeRuntime,
      version: 'Android Debug Bridge version 1.0.41',
      sha256:
          'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
      versionError: null,
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: Scaffold(
          body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag),
        ),
      ),
    );

    // Navigate to 설정.
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('설정'),
      ),
    );
    await tester.pumpAndSettle();

    // Trigger diagnostics load via refresh button.
    await tester.tap(find.byIcon(Icons.refresh));
    await tester.pumpAndSettle();

    expect(find.byKey(const ValueKey('diag-source')), findsOneWidget);
    expect(find.byKey(const ValueKey('diag-path')), findsOneWidget);
    expect(find.byKey(const ValueKey('diag-port')), findsOneWidget);
    expect(find.byKey(const ValueKey('diag-version')), findsOneWidget);
    expect(find.byKey(const ValueKey('diag-hash')), findsOneWidget);
    expect(find.text('bundled'), findsOneWidget);
    expect(find.text('5038'), findsOneWidget);
  });

  testWidgets('SettingsPage shows version error row when versionError is set', (
    tester,
  ) async {
    final fakeRuntime = AdbRuntime(
      executablePath: 'adb',
      source: AdbExecutableSource.system,
      serverPort: 5037,
      fallbackUsed: true,
    );
    final fakeDiag = AdbRuntimeDiagnostics(
      runtime: fakeRuntime,
      version: null,
      sha256: null,
      versionError: 'exit code 127',
    );

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: Scaffold(
          body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag),
        ),
      ),
    );

    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('설정'),
      ),
    );
    await tester.pumpAndSettle();

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

    expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget);
    expect(find.byKey(const ValueKey('diag-version')), findsNothing);
    expect(find.text('system (fallback)'), findsOneWidget);
  });

  testWidgets(
    'SettingsPage ADB diagnostics has no overflow at compact viewport',
    (tester) async {
      tester.view.physicalSize = const Size(900, 600);
      tester.view.devicePixelRatio = 1.0;
      addTearDown(tester.view.resetPhysicalSize);

      final fakeRuntime = AdbRuntime(
        executablePath:
            '/very/long/path/to/the/adb/executable/that/will/overflow/if/not/ellipsized',
        source: AdbExecutableSource.custom,
        serverPort: 15037,
      );
      final fakeDiag = AdbRuntimeDiagnostics(
        runtime: fakeRuntime,
        version: 'Android Debug Bridge version 1.0.41\nVersion 35.0.2-12147458',
        sha256:
            'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
        versionError: null,
      );

      await tester.pumpWidget(
        MaterialApp(
          theme: AppTheme.light(),
          home: Scaffold(
            body: AppSokShell(adbDiagnosticsLoader: () async => fakeDiag),
          ),
        ),
      );

      await tester.tap(
        find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text('설정'),
        ),
      );
      await tester.pumpAndSettle();

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

      // No RenderFlex overflow exception means test passes.
      expect(tester.takeException(), isNull);
      expect(find.byKey(const ValueKey('diag-path')), findsOneWidget);
    },
  );

  testWidgets(
    'SettingsPage without diagnostics loader shows no diagnostics section',
    (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          theme: AppTheme.light(),
          home: const Scaffold(body: AppSokShell()),
        ),
      );

      await tester.tap(
        find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text('설정'),
        ),
      );
      await tester.pumpAndSettle();

      expect(find.byKey(const ValueKey('diag-source')), findsNothing);
    },
  );

  testWidgets(
    'AppSokApp wires diagnostics loader and displays diagnostics in settings',
    (tester) async {
      const fakePath = '/opt/appsok/adb-bundled';
      const fakeVersion = 'Android Debug Bridge version 1.0.41';
      const fakeSha =
          'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
      const fakePort = 5038;

      final fakeRuntime = AdbRuntime(
        executablePath: fakePath,
        source: AdbExecutableSource.bundled,
        serverPort: fakePort,
        bundledExists: true,
        fallbackUsed: false,
      );
      final fakeDiag = AdbRuntimeDiagnostics(
        runtime: fakeRuntime,
        version: fakeVersion,
        sha256: fakeSha,
        versionError: null,
      );

      await tester.pumpWidget(
        AppSokApp(adbDiagnosticsLoader: () async => fakeDiag),
      );
      await tester.pumpAndSettle();

      await tester.tap(
        find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text('설정'),
        ),
      );
      await tester.pumpAndSettle();

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

      expect(find.text('ADB 진단'), findsOneWidget);
      expect(find.text('bundled'), findsOneWidget);
      expect(find.text(fakePath), findsOneWidget);
      expect(find.text(fakeVersion), findsOneWidget);
      expect(find.text(fakeSha.substring(0, 12)), findsOneWidget);
      expect(find.text(fakePort.toString()), findsOneWidget);
    },
  );

  testWidgets(
    'AppSokApp wires fallback diagnostics and shows system (fallback) source',
    (tester) async {
      final fakeRuntime = AdbRuntime(
        executablePath: 'adb',
        source: AdbExecutableSource.system,
        serverPort: 5037,
        fallbackUsed: true,
      );
      final fakeDiag = AdbRuntimeDiagnostics(
        runtime: fakeRuntime,
        version: null,
        sha256: null,
        versionError: 'exit code 127',
      );

      await tester.pumpWidget(
        AppSokApp(adbDiagnosticsLoader: () async => fakeDiag),
      );
      await tester.pumpAndSettle();

      await tester.tap(
        find.descendant(
          of: find.byType(NavigationRail),
          matching: find.text('설정'),
        ),
      );
      await tester.pumpAndSettle();

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

      expect(find.text('system (fallback)'), findsOneWidget);
      expect(find.byKey(const ValueKey('diag-version-error')), findsOneWidget);
    },
  );

  // ── AppSokApp web login surface wiring ──────────────────────────

  testWidgets('AppSokApp wires web login launcher into settings page', (
    tester,
  ) async {
    JenkinsWebLoginResult? capturedResult;

    await tester.pumpWidget(
      AppSokApp(
        webLoginLauncher: (_) async => const JenkinsWebLoginResult(
          username: 'toki',
          displayName: 'Toki Lab',
          apiToken: 'issued-token',
        ),
        onWebLoginCompleted: (result) => capturedResult = result,
      ),
    );
    await tester.pumpAndSettle();

    // Navigate to Settings
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('설정'),
      ),
    );
    await tester.pumpAndSettle();

    // Enter a valid Jenkins URL to enable the button
    await tester.enterText(
      find.byType(TextField).first,
      'https://jenkins.example.com',
    );
    await tester.pump();

    // Tap Web Login — should call launcher and complete
    await tester.tap(find.byKey(const ValueKey('jenkins-web-login-button')));
    await tester.pumpAndSettle();

    // onWebLoginCompleted callback must have been called
    expect(capturedResult, isNotNull);
    expect(capturedResult!.username, 'toki');

    // Result must be visible on screen
    expect(find.byKey(const ValueKey('web-login-result')), findsOneWidget);
    expect(find.textContaining('Toki Lab'), findsOneWidget);
  });

  testWidgets('ready device logcat button opens console for that device', (
    tester,
  ) async {
    final fakeProcess = _FakeProcess();
    final session = AdbLogcatSession(fakeProcess);
    var startCalls = 0;
    String? startedSerial;

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          deviceLoader: () async => const [
            AdbDevice(
              serial: 'device-ready-logcat',
              state: 'device',
              model: 'Pixel_8',
              product: 'shiba',
            ),
          ],
          logcatStarter: ({serial}) async {
            startCalls++;
            startedSerial = serial;
            return session;
          },
        ),
      ),
    );

    await tester.pumpAndSettle();

    // Navigate to 디바이스
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('디바이스'),
      ),
    );
    await tester.pumpAndSettle();

    expect(find.text('Pixel 8'), findsOneWidget);

    // Tap logcat button on the ready device
    final logcatButton = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == 'logcat',
    );
    await tester.tap(logcatButton.first);
    await tester.pumpAndSettle();

    // Check we navigated to 콘솔
    expect(startCalls, 1);
    expect(startedSerial, 'device-ready-logcat');
    expect(find.text('콘솔'), findsWidgets);

    // Verify selected device identity is displayed on console
    expect(find.textContaining('Pixel 8'), findsOneWidget);
    expect(find.textContaining('device-ready-logcat'), findsOneWidget);
  });

  testWidgets('navigation away or device change stops logcat session', (
    tester,
  ) async {
    final fakeProcess1 = _FakeProcess();
    final session1 = AdbLogcatSession(fakeProcess1);
    final fakeProcess2 = _FakeProcess();
    final session2 = AdbLogcatSession(fakeProcess2);

    var startCalls = 0;
    final sessions = [session1, session2];

    await tester.pumpWidget(
      MaterialApp(
        theme: AppTheme.light(),
        home: AppSokShell(
          deviceLoader: () async => const [
            AdbDevice(
              serial: 'device-1',
              state: 'device',
              model: 'Pixel_8',
              product: 'shiba',
            ),
            AdbDevice(
              serial: 'device-2',
              state: 'device',
              model: 'Galaxy_S23',
              product: 'dm3q',
            ),
          ],
          logcatStarter: ({serial}) async {
            final s = sessions[startCalls];
            startCalls++;
            return s;
          },
        ),
      ),
    );

    await tester.pumpAndSettle();

    // Navigate to 디바이스
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('디바이스'),
      ),
    );
    await tester.pumpAndSettle();

    // Tap logcat button on device-1
    final logcatButtons = find.byWidgetPredicate(
      (w) => w is IconButton && w.tooltip == 'logcat',
    );
    await tester.tap(logcatButtons.at(0));
    await tester.pumpAndSettle();

    expect(startCalls, 1);
    expect(fakeProcess1.killCalled, isFalse);

    // Navigate to 디바이스 again (leaving ConsolePage)
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('디바이스'),
      ),
    );
    await tester.pumpAndSettle();

    expect(fakeProcess1.killCalled, isTrue);

    // Tap logcat button on device-2
    await tester.tap(logcatButtons.at(1));
    await tester.pumpAndSettle();

    expect(startCalls, 2);
    expect(fakeProcess2.killCalled, isFalse);

    // Change selected console device by tapping logcat on device-1
    await tester.tap(
      find.descendant(
        of: find.byType(NavigationRail),
        matching: find.text('디바이스'),
      ),
    );
    await tester.pumpAndSettle();

    // Tap device-1 logcat
    await tester.tap(logcatButtons.at(0));
    await tester.pumpAndSettle();

    // Now session 2 (device-2) must be stopped because device changed
    expect(fakeProcess2.killCalled, isTrue);
  });
}

class _FakeProcess implements Process {
  final _stdout = StreamController<List<int>>();
  final _stderr = StreamController<List<int>>();
  final _exitCode = Completer<int>();

  bool killCalled = false;

  void emitStdout(String text) => _stdout.add(utf8.encode(text));
  void emitStderr(String text) => _stderr.add(utf8.encode(text));

  @override
  Stream<List<int>> get stdout => _stdout.stream;

  @override
  Stream<List<int>> get stderr => _stderr.stream;

  @override
  Future<int> get exitCode => _exitCode.future;

  @override
  bool kill([ProcessSignal signal = ProcessSignal.sigterm]) {
    killCalled = true;
    if (!_exitCode.isCompleted) _exitCode.complete(-15);
    _stdout.close();
    _stderr.close();
    return true;
  }

  @override
  int get pid => 4242;

  @override
  IOSink get stdin => throw UnimplementedError();
}
