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

import 'package:crypto/crypto.dart' as crypto;

import '../models/adb_device.dart';

typedef AdbProcessRunner =
    Future<ProcessResult> Function(
      String executable,
      List<String> arguments, {
      Map<String, String>? environment,
    });

typedef AdbProcessStarter =
    Future<Process> Function(
      String executable,
      List<String> arguments, {
      Map<String, String>? environment,
    });

typedef AdbLogcatSessionStarter =
    Future<AdbLogcatSession> Function({
      String? serial,
    });

class AdbService {
  AdbService({
    String? adbPath,
    AdbRuntimeConfig runtimeConfig = const AdbRuntimeConfig(),
    AdbProcessRunner processRunner = Process.run,
    AdbProcessStarter processStarter = Process.start,
  }) : runtimeConfig = adbPath == null
           ? runtimeConfig
           : runtimeConfig.copyWith(customAdbPath: adbPath),
       _processRunner = processRunner,
       _processStarter = processStarter;

  final AdbRuntimeConfig runtimeConfig;
  final AdbProcessRunner _processRunner;
  final AdbProcessStarter _processStarter;

  AdbRuntime get runtime => runtimeConfig.resolve();
  String get adbPath => runtime.executablePath;

  Future<List<AdbDevice>> listDevices() async {
    final result = await _run(['devices', '-l']);
    if (result.exitCode != 0) {
      throw AdbException(result.stderr.toString());
    }

    return result.stdout
        .toString()
        .split('\n')
        .map((line) => line.trim())
        .where((line) => line.isNotEmpty)
        .where((line) => line != 'List of devices attached')
        .where((line) => !line.startsWith('*'))
        .map(_parseDeviceLine)
        .whereType<AdbDevice>()
        .toList();
  }

  Future<AdbInstallResult> installApk({
    required String serial,
    required String apkPath,
    bool replace = true,
  }) async {
    final args = ['-s', serial, 'install', if (replace) '-r', apkPath];
    final result = await _run(args);

    return AdbInstallResult(
      exitCode: result.exitCode,
      stdout: result.stdout.toString(),
      stderr: result.stderr.toString(),
    );
  }

  Future<AdbLogcatSession> startLogcat({String? serial}) async {
    final args = [
      if (serial != null) ...['-s', serial],
      'logcat',
    ];
    final runtime = this.runtime;
    final process = await _processStarter(
      runtime.executablePath,
      args,
      environment: runtime.environment,
    );
    return AdbLogcatSession(process);
  }

  /// Compatibility wrapper over [startLogcat]. Cancelling the returned
  /// subscription stops the underlying logcat session and kills the process,
  /// so no orphan ADB process is left running.
  Stream<String> logcat({String? serial}) async* {
    final session = await startLogcat(serial: serial);
    try {
      yield* session.lines;
    } finally {
      await session.stop();
    }
  }

  Future<AdbRuntimeDiagnostics> collectDiagnostics() async {
    final rt = runtime;
    String? version;
    String? versionError;
    String? sha256;

    // Collect adb version output. Timeout so a hung adb server never blocks callers.
    try {
      final result = await _processRunner(rt.executablePath, [
        'version',
      ], environment: rt.environment).timeout(const Duration(seconds: 5));
      if (result.exitCode == 0) {
        version = result.stdout.toString().trim();
      } else {
        versionError = result.stderr.toString().trim();
        if (versionError.isEmpty) {
          versionError = 'exit code ${result.exitCode}';
        }
      }
    } catch (e) {
      versionError = e.toString();
    }

    // Compute SHA-256 of the resolved executable if it exists.
    try {
      final file = File(rt.executablePath);
      if (file.existsSync()) {
        final bytes = file.readAsBytesSync();
        sha256 = sha256Digest(bytes);
      }
    } catch (_) {
      // Hash is best-effort; leave null if unreadable.
    }

    return AdbRuntimeDiagnostics(
      runtime: rt,
      version: version,
      sha256: sha256,
      versionError: versionError,
    );
  }

  static String sha256Digest(List<int> bytes) {
    return crypto.sha256.convert(bytes).toString();
  }

  Future<ProcessResult> _run(List<String> args) {
    final runtime = this.runtime;
    return _processRunner(
      runtime.executablePath,
      args,
      environment: runtime.environment,
    );
  }

  AdbDevice? _parseDeviceLine(String line) {
    final parts = line.split(RegExp(r'\s+'));
    if (parts.length < 2) return null;

    final metadata = <String, String>{};

    for (final part in parts.skip(2)) {
      final separator = part.indexOf(':');
      if (separator > 0 && separator < part.length - 1) {
        metadata[part.substring(0, separator)] = part.substring(separator + 1);
      }
    }

    return AdbDevice(
      serial: parts.first,
      state: parts[1],
      model: metadata['model'],
      product: metadata['product'],
      device: metadata['device'],
    );
  }
}

enum AdbExecutableSource { bundled, custom, system }

enum AdbServerMode { appSok, shared }

class AdbRuntimeConfig {
  const AdbRuntimeConfig({
    this.bundledAdbPath,
    this.customAdbPath,
    this.serverMode = AdbServerMode.appSok,
    this.serverPort = defaultAppSokServerPort,
    this.allowSystemFallback = true,
  }) : assert(serverPort > 0 && serverPort <= 65535);

  static const defaultAppSokServerPort = 5038;
  static const sharedServerPort = 5037;
  static const systemAdbPath = 'adb';

  final String? bundledAdbPath;
  final String? customAdbPath;
  final AdbServerMode serverMode;
  final int serverPort;
  final bool allowSystemFallback;

  AdbRuntimeConfig copyWith({
    String? bundledAdbPath,
    String? customAdbPath,
    AdbServerMode? serverMode,
    int? serverPort,
    bool? allowSystemFallback,
  }) {
    return AdbRuntimeConfig(
      bundledAdbPath: bundledAdbPath ?? this.bundledAdbPath,
      customAdbPath: customAdbPath ?? this.customAdbPath,
      serverMode: serverMode ?? this.serverMode,
      serverPort: serverPort ?? this.serverPort,
      allowSystemFallback: allowSystemFallback ?? this.allowSystemFallback,
    );
  }

  AdbRuntime resolve() {
    final explicitCustomPath = _trimmedOrNull(customAdbPath);
    if (explicitCustomPath != null) {
      return _runtime(
        executablePath: explicitCustomPath,
        source: AdbExecutableSource.custom,
      );
    }

    final expectedBundledPath = effectiveBundledAdbPath;
    final bundledExists =
        expectedBundledPath != null && File(expectedBundledPath).existsSync();
    if (bundledExists) {
      return _runtime(
        executablePath: expectedBundledPath,
        source: AdbExecutableSource.bundled,
        expectedBundledPath: expectedBundledPath,
        bundledExists: bundledExists,
      );
    }

    if (allowSystemFallback) {
      return _runtime(
        executablePath: systemAdbPath,
        source: AdbExecutableSource.system,
        expectedBundledPath: expectedBundledPath,
        bundledExists: bundledExists,
        fallbackUsed: true,
      );
    }

    return _runtime(
      executablePath: expectedBundledPath ?? systemAdbPath,
      source: AdbExecutableSource.bundled,
      expectedBundledPath: expectedBundledPath,
      bundledExists: bundledExists,
    );
  }

  String? get effectiveBundledAdbPath {
    final explicitBundledPath = _trimmedOrNull(bundledAdbPath);
    if (explicitBundledPath != null) return explicitBundledPath;

    if (!Platform.isMacOS) return null;

    final executable = File(Platform.resolvedExecutable);
    final executableDir = executable.parent;
    if (executableDir.path.endsWith('/Contents/MacOS')) {
      return '${executableDir.parent.path}/Resources/adb-runtime/adb';
    }

    return '${Directory.current.path}/macos/Runner/Resources/adb-runtime/adb';
  }

  int get effectiveServerPort =>
      serverMode == AdbServerMode.shared ? sharedServerPort : serverPort;

  AdbRuntime _runtime({
    required String executablePath,
    required AdbExecutableSource source,
    String? expectedBundledPath,
    bool bundledExists = false,
    bool fallbackUsed = false,
  }) {
    return AdbRuntime(
      executablePath: executablePath,
      source: source,
      serverPort: effectiveServerPort,
      expectedBundledPath: expectedBundledPath,
      bundledExists: bundledExists,
      fallbackUsed: fallbackUsed,
    );
  }

  static String? _trimmedOrNull(String? value) {
    final trimmed = value?.trim();
    return trimmed == null || trimmed.isEmpty ? null : trimmed;
  }
}

class AdbRuntime {
  const AdbRuntime({
    required this.executablePath,
    required this.source,
    required this.serverPort,
    this.expectedBundledPath,
    this.bundledExists = false,
    this.fallbackUsed = false,
  });

  final String executablePath;
  final AdbExecutableSource source;
  final int serverPort;
  final String? expectedBundledPath;
  final bool bundledExists;
  final bool fallbackUsed;

  Map<String, String> get environment => {
    'ADB_SERVER_PORT': serverPort.toString(),
  };
}

/// A running `adb logcat` session.
///
/// Process lifetime is controlled only by [stop]: a direct session user MUST
/// call [stop] to kill the underlying process and avoid leaving an orphan ADB
/// process. Cancelling a [lines] subscription on its own does not stop the
/// process. ([AdbService.logcat] is a convenience wrapper that ties stream
/// cancellation to [stop] for callers that don't manage the session directly.)
class AdbLogcatSession {
  AdbLogcatSession(this._process);

  final Process _process;
  var _stopped = false;

  /// Merged stdout/stderr of the logcat process, decoded as text lines.
  ///
  /// This getter only exposes output; it does not own process lifetime.
  /// Call [stop] to terminate the process.
  Stream<String> get lines {
    final stdout = _process.stdout
        .transform(utf8.decoder)
        .transform(const LineSplitter());
    final stderr = _process.stderr
        .transform(utf8.decoder)
        .transform(const LineSplitter());

    return StreamGroup.merge([stdout, stderr]);
  }

  /// Kills the logcat process. Safe to call more than once.
  Future<void> stop() async {
    if (_stopped) return;
    _stopped = true;
    _process.kill();
    await _process.exitCode;
  }
}

class AdbInstallResult {
  const AdbInstallResult({
    required this.exitCode,
    required this.stdout,
    required this.stderr,
  });

  final int exitCode;
  final String stdout;
  final String stderr;

  bool get succeeded => exitCode == 0;
}

class AdbException implements Exception {
  const AdbException(this.message);

  final String message;

  @override
  String toString() => 'AdbException: $message';
}

typedef AdbDiagnosticsLoader = Future<AdbRuntimeDiagnostics> Function();

class AdbRuntimeDiagnostics {
  const AdbRuntimeDiagnostics({
    required this.runtime,
    required this.version,
    required this.sha256,
    required this.versionError,
  });

  final AdbRuntime runtime;
  final String? version;
  final String? sha256;
  final String? versionError;
}

class StreamGroup {
  static Stream<T> merge<T>(Iterable<Stream<T>> streams) {
    final controller = StreamController<T>();
    var pending = streams.length;

    for (final stream in streams) {
      stream.listen(
        controller.add,
        onError: controller.addError,
        onDone: () {
          pending -= 1;
          if (pending == 0) {
            controller.close();
          }
        },
      );
    }

    return controller.stream;
  }
}
