// Hub fixture for integration-level tests.
//
// Spawns a real `fai serve` subprocess on a free port in a
// temporary directory, waits for its gRPC endpoint to answer
// `Healthy`, and exposes the port + a teardown so test files
// can talk to a clean Hub without setting up an isolate-side
// gRPC server in-process.
//
// Cost / scope notes:
//
// * The `fai` binary must be on the operator's PATH or at
//   `target/release/fai` relative to the platform repo. We
//   resolve those two paths in order; if neither exists, the
//   fixture skips the suite with a clear message rather than
//   failing — these tests are opt-in.
// * The hub is started with `--quiet` (no log spam) against a
//   temp dir, so the operator's real `~/.fai/` stays untouched.
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
//   after a grace period. The temp dir is removed.
// * Each fixture is independent — call `HubFixture.start()` in
//   `setUp`, `fixture.dispose()` in `tearDown`. Don't share
//   between tests; the few seconds of startup beat debugging
//   state-bleed.

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

import 'package:fai_client_sdk/fai_client_sdk.dart';
import 'package:flutter_test/flutter_test.dart';

class HubFixture {
  /// The hub subprocess. Kept private so callers go through
  /// [dispose] for shutdown.
  final Process _process;

  /// gRPC port the hub is listening on. Free port chosen by
  /// the OS at startup time.
  final int port;

  /// Temp directory the hub was started against. Wiped in
  /// [dispose] so tests leave no state behind on disk.
  final Directory tempDir;

  /// Pre-connected SDK client. Reuse across the test's
  /// assertions — gRPC channel creation is cheap but
  /// non-zero, and the tests don't benefit from churning it.
  final HubClient client;

  HubFixture._({
    required Process process,
    required this.port,
    required this.tempDir,
    required this.client,
  }) : _process = process;

  /// Boots a fresh hub against a temp dir and waits up to
  /// `readyTimeout` for it to answer Healthy. Use
  /// [skipIfBinaryMissing] = true (default) to let tests
  /// `markSkipped` instead of failing when the binary isn't
  /// present — that's the desired behaviour in CI environments
  /// that don't build the binary first.
  static Future<HubFixture?> start({
    // A cold hub spends its first 30s building the curated-
    // model database + initialising SQLite. 60s gives that
    // honest headroom without making a hung hub look like
    // a slow one.
    Duration readyTimeout = const Duration(seconds: 60),
    bool skipIfBinaryMissing = true,
  }) async {
    final binary = await _resolveBinary();
    if (binary == null) {
      if (skipIfBinaryMissing) {
        markTestSkipped(
          'Hub fixture skipped: no `fai` binary found on PATH or in '
          '../fai_platform/target/release/fai. Build it with '
          '`cargo build --release` and re-run.',
        );
        return null;
      }
      throw StateError('fai binary not found');
    }

    final tempDir = await Directory.systemTemp.createTemp('fai_studio_test_');
    final port = await _pickFreePort();
    final addr = '127.0.0.1:$port';

    // Run as a child process. `serve --bind` lets us avoid the
    // default 50051 (and any local daemon the developer happens
    // to be running). FAI_DATA_DIR keeps the temp footprint
    // contained — no state leaks into ~/.fai.
    final process = await Process.start(
      binary,
      ['serve', '--bind', addr],
      environment: {
        ...Platform.environment,
        'FAI_DATA_DIR': tempDir.path,
        'FAI_MODULES_DIR': '${tempDir.path}/modules',
        // Suppress info logs without a CLI flag — the binary
        // respects RUST_LOG for tracing-subscriber.
        'RUST_LOG': 'warn',
      },
    );

    // Drain stdout/stderr to keep the OS pipe buffers from
    // blocking the hub. Test failures still surface the last
    // ~64 KiB of output via the dispose path.
    process.stdout.transform(SystemEncoding().decoder).listen(_noop);
    process.stderr.transform(SystemEncoding().decoder).listen(_noop);

    // Poll the hub until it's Healthy. Bound by readyTimeout —
    // a hung hub is a test failure, not a hang.
    final client = HubClient(
      endpoint: HubEndpoint(host: '127.0.0.1', port: port),
    );
    final deadline = DateTime.now().add(readyTimeout);
    while (DateTime.now().isBefore(deadline)) {
      try {
        final ok = await client.healthy();
        if (ok) {
          return HubFixture._(
            process: process,
            port: port,
            tempDir: tempDir,
            client: client,
          );
        }
      } catch (_) {
        // Hub still booting; try again.
      }
      await Future.delayed(const Duration(milliseconds: 100));
    }

    // Boot didn't complete in time. Tear down + fail with a
    // useful message.
    process.kill(ProcessSignal.sigterm);
    await tempDir.delete(recursive: true);
    throw StateError(
      'Hub did not become healthy within $readyTimeout. '
      'Check `fai serve` works manually with FAI_DATA_DIR=$tempDir.',
    );
  }

  /// Shuts down the hub and removes the temp dir. Idempotent
  /// so a teardown that runs after a test-failure exit still
  /// cleans up.
  Future<void> dispose() async {
    await client.close();
    _process.kill(ProcessSignal.sigterm);
    // Give the hub up to 3 seconds to flush its event log.
    try {
      await _process.exitCode.timeout(const Duration(seconds: 3));
    } on TimeoutException {
      _process.kill(ProcessSignal.sigkill);
    }
    if (await tempDir.exists()) {
      await tempDir.delete(recursive: true);
    }
  }

  /// Look for `fai` first on PATH (production-ish), then in
  /// the standard `target/release/` build output relative to
  /// the platform checkout (developer flow). Returns null when
  /// neither path resolves.
  static Future<String?> _resolveBinary() async {
    final pathResult = await Process.run(
      Platform.isWindows ? 'where' : 'which',
      ['fai'],
    );
    if (pathResult.exitCode == 0) {
      final s = (pathResult.stdout as String).trim();
      if (s.isNotEmpty) return s.split('\n').first.trim();
    }

    final candidate = Platform.isWindows
        ? '../fai_platform/target/release/fai.exe'
        : '../fai_platform/target/release/fai';
    final f = File(candidate);
    if (await f.exists()) {
      return f.absolute.path;
    }
    return null;
  }

  static Future<int> _pickFreePort() async {
    final s = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
    final port = s.port;
    await s.close();
    return port;
  }
}

void _noop(Object _) {}
