import 'dart:async';
import 'dart:io';
import 'package:oto/cli/cli.dart';
import 'package:oto/oto/agent/agent_config.dart';
import 'package:oto/oto/agent/registration_client.dart';
import 'package:oto/oto/agent/remote_run_executor.dart';
import 'package:oto/oto/commands/runner_command_capability_provider.dart';

abstract class AgentRunner {
  Future<void> run(AgentConfig config);
}

class RegistrationException implements Exception {
  final String message;
  RegistrationException(this.message);

  @override
  String toString() => message;
}

class DefaultAgentRunner implements AgentRunner {
  final RegistrationClient _client;
  final void Function(String)? _onLog;
  final Future<void> Function(AgentConfig, AgentSession)? _runJobs;
  // Injected shutdown future for tests; null means use real OS signals.
  final Future<void>? _shutdownFuture;

  DefaultAgentRunner({
    RegistrationClient? client,
    void Function(String)? onLog,
    Future<void> Function(AgentConfig, AgentSession)? runJobs,
    Future<void>? shutdownFuture,
  }) : _client =
           client ??
           OtoServerSocketRegistrationClient(
             capabilityProvider: const CommandCatalogRunnerCapabilityProvider(),
           ),
       _onLog = onLog,
       _runJobs = runJobs,
       _shutdownFuture = shutdownFuture;

  @override
  Future<void> run(AgentConfig config) async {
    _log('Starting registration with OTO Server at "${config.server.url}"...');

    final AgentSession session;
    try {
      session = await _client.openSession(config);
    } on RegistrationException {
      rethrow;
    } catch (e) {
      throw RegistrationException('Connection failed: $e');
    }

    try {
      final result = session.result;

      if (!result.accepted) {
        throw RegistrationException(
          result.rejectReason ?? 'Registration was rejected by the OTO Server.',
        );
      }

      _log('Registration successful!');
      _log('Runner ID: ${result.runnerId}');
      if (result.alias != null) {
        _log('Alias: ${result.alias}');
      }

      // Build a single shutdown future used both by the job loop and
      // _awaitShutdown so signal listeners are not duplicated.
      final shutdownFuture = _shutdownFuture ?? _buildSignalShutdownFuture();

      var loopWaitedForShutdown = false;
      if (_runJobs != null) {
        await _runJobs(config, session);
      } else if (session is OtoServerPushJobSession) {
        await _pushJobLoop(config, session, shutdownFuture);
        loopWaitedForShutdown = true;
      } else if (session is OtoServerJobSession) {
        // Compatibility fallback: run HTTP polling job loop when proto-socket is unavailable.
        _log('Falling back to compatibility HTTP polling mode...');
        await _compatibilityPollingLoop(config, session, shutdownFuture);
        loopWaitedForShutdown = true;
      }

      if (!loopWaitedForShutdown) {
        _log('Agent connected. Waiting for shutdown signal...');
        await shutdownFuture;
      }
    } finally {
      await session.close();
    }
  }

  /// Default proto-socket job loop: the server pushes RunRequest messages over
  /// the open runner socket. Jobs are executed sequentially per agent process.
  Future<void> _pushJobLoop(
    AgentConfig config,
    OtoServerPushJobSession session,
    Future<void> shutdownFuture,
  ) async {
    final executor = RemoteRunExecutor(
      parser: RunRequestParser(workspaceRoot: config.runtime.workspaceRoot),
      jobClient: session.jobReporter,
      onLog: _log,
    );

    var runChain = Future<void>.value();
    final runSubscription = session.runRequests.listen((request) {
      runChain = runChain.then((_) async {
        if (request.jobId.isEmpty || request.executionId.isEmpty) {
          _log('Received RunRequest without job_id or execution_id; skipping.');
          return;
        }
        final buildResult = await executor.runOnce(
          jobId: request.jobId,
          executionId: request.executionId,
          runRequest: request,
        );
        if (!buildResult.success) {
          _log(
            'Job ${request.jobId} failed: '
            '${buildResult.message}',
          );
        }
      });
    });
    final cancelSubscription = session.cancelRequests.listen((request) {
      final executionId = request.executionId;
      if (executionId.isEmpty) {
        _log('Received CancelRunRequest without execution_id; skipping.');
        return;
      }
      _log(
        'Received cancel for execution $executionId; '
        'server state guard owns terminal conflict handling.',
      );
    });

    _log('Agent connected via proto-socket. Waiting for pushed jobs...');
    await shutdownFuture;
    await runSubscription.cancel();
    await cancelSubscription.cancel();
    await runChain;
  }

  /// Compatibility fallback remote job loop: claim -> execute -> report, polling the
  /// OTO Server for new jobs. Uses [runOnce] so tests can drive a single
  /// iteration without an infinite loop.
  ///
  /// [shutdownFuture] completes when a stop is requested (SIGINT/SIGTERM or
  /// injected future). The loop exits promptly even while waiting between
  /// polls by racing the delay against [shutdownFuture].
  Future<void> _compatibilityPollingLoop(
    AgentConfig config,
    OtoServerJobSession session,
    Future<void> shutdownFuture,
  ) async {
    final executor = RemoteRunExecutor(
      parser: RunRequestParser(workspaceRoot: config.runtime.workspaceRoot),
      jobClient: session.jobs,
      onLog: _log,
    );

    var shutdownRequested = false;
    // Mark shutdown as soon as the future completes so the while-check exits.
    shutdownFuture.then(
      (_) {
        shutdownRequested = true;
      },
      onError: (_) {
        shutdownRequested = true;
      },
    );

    while (!shutdownRequested) {
      final claim = await session.jobs.claimNextJob();
      if (!claim.accepted || claim.jobId.isEmpty) {
        // No job available; wait before polling again, but exit early on shutdown.
        await Future.any([
          Future<void>.delayed(const Duration(seconds: 3)),
          shutdownFuture,
        ]);
        continue;
      }

      if (shutdownRequested) break;

      if (claim.runRequest == null) {
        // Claimed but no RunRequest; skip.
        _log('Claimed job ${claim.jobId} but no run_request; skipping.');
        continue;
      }

      // Execute the job.
      final buildResult = await executor.runOnce(
        jobId: claim.jobId,
        executionId: claim.executionId,
        runRequest: claim.runRequest!,
      );

      if (!buildResult.success) {
        _log(
          'Job ${claim.jobId} failed: '
          '${buildResult.message}',
        );
      }
    }
  }

  /// Builds a future that completes when SIGINT or SIGTERM is received.
  /// Signal listeners are held in a local Completer and cancelled on done.
  Future<void> _buildSignalShutdownFuture() {
    final completer = Completer<void>();
    final subscriptions = <StreamSubscription<ProcessSignal>>[];
    void requestStop() {
      if (!completer.isCompleted) completer.complete();
    }

    subscriptions
      ..add(ProcessSignal.sigint.watch().listen((_) => requestStop()))
      ..add(ProcessSignal.sigterm.watch().listen((_) => requestStop()));

    return completer.future.whenComplete(() {
      for (final sub in subscriptions) {
        sub.cancel();
      }
    });
  }

  void _log(String message) {
    if (_onLog != null) {
      _onLog(message);
    } else {
      CLI.println(message, color: Color.green);
    }
  }
}
