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

import 'package:http/http.dart' as http;
import 'package:oto/oto/agent/oto/runner.pb.dart' as oto;
import 'package:oto/oto/core/build_result.dart';
import 'package:oto/oto/core/execution_context.dart';
import 'package:proto_socket/proto_socket.dart';

class JobClaimResult {
  final bool accepted;
  final String? errorMessage;
  final String jobId;
  final String executionId;
  final String state;
  final oto.RunRequest? runRequest;

  const JobClaimResult({
    required this.accepted,
    required this.jobId,
    required this.executionId,
    required this.state,
    this.errorMessage,
    this.runRequest,
  });

  factory JobClaimResult.fromJson(Map<String, dynamic> json) {
    final err = json['error'];
    final errMessageFromObj = (err is Map) ? err['message'] : null;
    return JobClaimResult(
      accepted: json['accepted'] == true,
      errorMessage: _emptyToNull(
        (json['error_message'] ??
                json['errorMessage'] ??
                errMessageFromObj ??
                '')
            .toString(),
      ),
      jobId: (json['job_id'] ?? json['jobId'] ?? '').toString(),
      executionId: (json['execution_id'] ?? json['executionId'] ?? '')
          .toString(),
      state: (json['state'] ?? '').toString(),
      runRequest: _parseRunRequest(json['run_request'] ?? json['runRequest']),
    );
  }
}

class ExecutionReportResult {
  final bool accepted;
  final String? errorMessage;
  final String jobId;
  final String executionId;
  final String state;

  const ExecutionReportResult({
    required this.accepted,
    required this.jobId,
    required this.executionId,
    required this.state,
    this.errorMessage,
  });

  factory ExecutionReportResult.fromJson(Map<String, dynamic> json) {
    final err = json['error'];
    final errMessageFromObj = (err is Map) ? err['message'] : null;
    return ExecutionReportResult(
      accepted: json['accepted'] == true,
      errorMessage: _emptyToNull(
        (json['error_message'] ??
                json['errorMessage'] ??
                errMessageFromObj ??
                '')
            .toString(),
      ),
      jobId: (json['job_id'] ?? json['jobId'] ?? '').toString(),
      executionId: (json['execution_id'] ?? json['executionId'] ?? '')
          .toString(),
      state: (json['state'] ?? '').toString(),
    );
  }
}

class RunnerCancelResult {
  final bool success;
  final String? errorMessage;

  const RunnerCancelResult({required this.success, this.errorMessage});

  factory RunnerCancelResult.fromJson(Map<String, dynamic> json) {
    final err = json['error'];
    final errMessageFromObj = (err is Map) ? err['message'] : null;
    return RunnerCancelResult(
      success: json['success'] == true,
      errorMessage: _emptyToNull(
        (json['error_message'] ??
                json['errorMessage'] ??
                errMessageFromObj ??
                '')
            .toString(),
      ),
    );
  }
}

class RunnerStatusResult {
  final bool accepted;
  final String runnerId;
  final String status;
  final String? currentJobId;
  final String? currentExecutionId;
  final String? message;

  const RunnerStatusResult({
    required this.accepted,
    required this.runnerId,
    required this.status,
    this.currentJobId,
    this.currentExecutionId,
    this.message,
  });

  factory RunnerStatusResult.fromJson(Map<String, dynamic> json) {
    return RunnerStatusResult(
      accepted: json['accepted'] == true,
      runnerId: (json['runner_id'] ?? json['runnerId'] ?? '').toString(),
      status: (json['status'] ?? '').toString(),
      currentJobId: _emptyToNull(
        (json['current_job_id'] ?? json['currentJobId'] ?? '').toString(),
      ),
      currentExecutionId: _emptyToNull(
        (json['current_execution_id'] ?? json['currentExecutionId'] ?? '')
            .toString(),
      ),
      message: _emptyToNull((json['message'] ?? '').toString()),
    );
  }
}

class RunnerSelfUpdateResult {
  final bool success;
  final bool accepted;
  final bool deferred;
  final String? targetVersion;
  final String? message;

  const RunnerSelfUpdateResult({
    required this.success,
    required this.accepted,
    required this.deferred,
    this.targetVersion,
    this.message,
  });

  factory RunnerSelfUpdateResult.fromJson(Map<String, dynamic> json) {
    return RunnerSelfUpdateResult(
      success: json['success'] == true,
      accepted: json['accepted'] == true,
      deferred: json['deferred'] == true,
      targetVersion: _emptyToNull(
        (json['target_version'] ?? json['targetVersion'] ?? '').toString(),
      ),
      message: _emptyToNull((json['message'] ?? '').toString()),
    );
  }
}

abstract class RemoteJobReporter {
  Future<ExecutionReportResult> reportExecution({
    required String jobId,
    required String executionId,
    required BuildResult result,
  });

  Future<void> appendLog({required String executionId, required String line});

  Future<void> reportArtifact({
    required String executionId,
    required String name,
    required String path,
  });

  Future<void> reportArtifacts({
    required String executionId,
    required List<BuildArtifact> artifacts,
  });
}

class OtoServerJobClient implements RemoteJobReporter {
  final String serverUrl;
  final String runnerId;
  final http.Client _client;
  final bool _shouldCloseClient;

  OtoServerJobClient({
    required this.serverUrl,
    required this.runnerId,
    http.Client? client,
  }) : _client = client ?? http.Client(),
       _shouldCloseClient = client == null;

  Future<JobClaimResult> claimJob({
    required String jobId,
    required String executionId,
  }) async {
    final request = oto.JobClaimRequest()
      ..runnerId = runnerId
      ..jobId = jobId
      ..executionId = executionId;
    final response = await _postJson(
      _runnerUri('/jobs/claim'),
      _jobClaimRequestJson(request),
    );
    return JobClaimResult.fromJson(response);
  }

  /// Compatibility fallback polling of the OTO Server for the next available job.
  ///
  /// Sends a compatibility fallback claim request with empty job_id and execution_id; the server will
  /// select the oldest queued job for this runner and generate an execution id.
  ///
  /// Returns [JobClaimResult] with [JobClaimResult.accepted] = false when no
  /// job is available (the server responds with accepted=false).
  ///
  /// Throws [HttpException] when the server returns HTTP 4xx/5xx (not masked as
  /// no-job so callers can back off appropriately).
  Future<JobClaimResult> claimNextJob() async {
    return claimJob(jobId: '', executionId: '');
  }

  @override
  Future<ExecutionReportResult> reportExecution({
    required String jobId,
    required String executionId,
    required BuildResult result,
  }) async {
    final request = oto.ExecutionReportRequest()
      ..runnerId = runnerId
      ..jobId = jobId
      ..executionId = executionId
      ..success = result.success
      ..exitCode = result.exitCode
      ..message = result.message
      ..stepEvents.addAll(result.stepEvents.map(_stepEventReport));
    final response = await _postJson(
      _runnerUri('/executions/${Uri.encodeComponent(executionId)}/report'),
      _executionReportRequestJson(request, result),
    );
    return ExecutionReportResult.fromJson(response);
  }

  @override
  Future<void> appendLog({
    required String executionId,
    required String line,
  }) async {
    final request = oto.LogAppendRequest()
      ..runnerId = runnerId
      ..executionId = executionId
      ..line = line;
    final response = await _postJson(
      _runnerUri('/executions/${Uri.encodeComponent(executionId)}/logs'),
      _logAppendRequestJson(request),
      expectedStatus: HttpStatus.created,
    );
    if (response['accepted'] != true) {
      final err = response['error'];
      final errMessageFromObj = (err is Map) ? err['message'] : null;
      throw HttpException(
        (response['error_message'] ??
                response['errorMessage'] ??
                errMessageFromObj ??
                'log rejected')
            .toString(),
      );
    }
  }

  @override
  Future<void> reportArtifact({
    required String executionId,
    required String name,
    required String path,
  }) async {
    final request = oto.ArtifactReportRequest()
      ..runnerId = runnerId
      ..executionId = executionId
      ..name = name
      ..path = path;
    final response = await _postJson(
      _runnerUri('/executions/${Uri.encodeComponent(executionId)}/artifacts'),
      _artifactReportRequestJson(request),
      expectedStatus: HttpStatus.created,
    );
    if (response['accepted'] != true) {
      final err = response['error'];
      final errMessageFromObj = (err is Map) ? err['message'] : null;
      throw HttpException(
        (response['error_message'] ??
                response['errorMessage'] ??
                errMessageFromObj ??
                'artifact rejected')
            .toString(),
      );
    }
  }

  /// Reports each declared [artifacts] entry in order via [reportArtifact].
  ///
  /// Stops and rethrows on the first failure so the caller can surface the HTTP
  /// error; partial reporting is preferred over silently swallowing failures.
  @override
  Future<void> reportArtifacts({
    required String executionId,
    required List<BuildArtifact> artifacts,
  }) async {
    for (final artifact in artifacts) {
      await reportArtifact(
        executionId: executionId,
        name: artifact.name,
        path: artifact.path,
      );
    }
  }

  Future<RunnerCancelResult> cancelRun({
    required String executionId,
    required String reason,
  }) async {
    final body = {
      'runner_id': runnerId,
      'execution_id': executionId,
      'reason': reason,
    };
    final response = await _postJson(
      _runnerUri('/executions/${Uri.encodeComponent(executionId)}/cancel'),
      body,
    );
    return RunnerCancelResult.fromJson(response);
  }

  Future<RunnerStatusResult> fetchRunnerStatus() async {
    final response = await _getJson(_runnerUri('/status'));
    return RunnerStatusResult.fromJson(response);
  }

  /// Requests the operator-facing HTTP compatibility self-update action.
  ///
  /// This milestone does not dispatch a socket self-update command; the server
  /// either accepts an idle compatibility request or defers while a run is active.
  Future<RunnerSelfUpdateResult> requestSelfUpdate({
    required String version,
    required String downloadUrl,
  }) async {
    final body = {
      'runner_id': runnerId,
      'version': version,
      'download_url': downloadUrl,
    };
    final response = await _postJson(_runnerUri('/self-update'), body);
    return RunnerSelfUpdateResult.fromJson(response);
  }

  void close() {
    if (_shouldCloseClient) {
      _client.close();
    }
  }

  Uri _runnerUri(String suffix) {
    final runnerPath =
        '/api/v1/runners/${Uri.encodeComponent(runnerId)}$suffix';
    return _apiUri(serverUrl, runnerPath);
  }

  Future<Map<String, dynamic>> _postJson(
    Uri uri,
    Map<String, dynamic> body, {
    int expectedStatus = HttpStatus.ok,
  }) async {
    final response = await _client
        .post(
          uri,
          headers: {'content-type': 'application/json'},
          body: jsonEncode(body),
        )
        .timeout(const Duration(seconds: 5));
    if (response.statusCode != expectedStatus) {
      throw HttpException(
        'OTO Server request failed with HTTP ${response.statusCode}',
      );
    }
    final decoded = jsonDecode(response.body);
    if (decoded is! Map<String, dynamic>) {
      throw const FormatException('Invalid OTO Server response body');
    }
    return decoded;
  }

  Future<Map<String, dynamic>> _getJson(
    Uri uri, {
    int expectedStatus = HttpStatus.ok,
  }) async {
    final response = await _client
        .get(uri, headers: {'accept': 'application/json'})
        .timeout(const Duration(seconds: 5));
    if (response.statusCode != expectedStatus) {
      throw HttpException(
        'OTO Server request failed with HTTP ${response.statusCode}',
      );
    }
    final decoded = jsonDecode(response.body);
    if (decoded is! Map<String, dynamic>) {
      throw const FormatException('Invalid OTO Server response body');
    }
    return decoded;
  }
}

class OtoServerSocketJobClient implements RemoteJobReporter {
  final String runnerId;
  final ProtobufClient _socket;

  OtoServerSocketJobClient({
    required this.runnerId,
    required ProtobufClient socket,
  }) : _socket = socket;

  @override
  Future<ExecutionReportResult> reportExecution({
    required String jobId,
    required String executionId,
    required BuildResult result,
  }) async {
    final request = oto.ExecutionReportRequest()
      ..runnerId = runnerId
      ..jobId = jobId
      ..executionId = executionId
      ..success = result.success
      ..exitCode = result.exitCode
      ..message = result.message
      ..stepEvents.addAll(result.stepEvents.map(_stepEventReport));
    final response = await _socket
        .sendRequest<oto.ExecutionReportRequest, oto.ExecutionReportResponse>(
          request,
        );
    return ExecutionReportResult(
      accepted: response.accepted,
      errorMessage: _emptyToNull(response.errorMessage),
      jobId: response.jobId,
      executionId: response.executionId,
      state: response.state,
    );
  }

  @override
  Future<void> appendLog({
    required String executionId,
    required String line,
  }) async {
    final request = oto.LogAppendRequest()
      ..runnerId = runnerId
      ..executionId = executionId
      ..line = line;
    final response = await _socket
        .sendRequest<oto.LogAppendRequest, oto.LogAppendResponse>(request);
    if (!response.accepted) {
      throw HttpException(
        response.errorMessage.isNotEmpty
            ? response.errorMessage
            : 'log rejected',
      );
    }
  }

  @override
  Future<void> reportArtifact({
    required String executionId,
    required String name,
    required String path,
  }) async {
    final request = oto.ArtifactReportRequest()
      ..runnerId = runnerId
      ..executionId = executionId
      ..name = name
      ..path = path;
    final response = await _socket
        .sendRequest<oto.ArtifactReportRequest, oto.ArtifactReportResponse>(
          request,
        );
    if (!response.accepted) {
      throw HttpException(
        response.errorMessage.isNotEmpty
            ? response.errorMessage
            : 'artifact rejected',
      );
    }
  }

  @override
  Future<void> reportArtifacts({
    required String executionId,
    required List<BuildArtifact> artifacts,
  }) async {
    for (final artifact in artifacts) {
      await reportArtifact(
        executionId: executionId,
        name: artifact.name,
        path: artifact.path,
      );
    }
  }
}

Uri _apiUri(String serverUrl, String endpointPath) {
  var raw = serverUrl;
  if (!raw.contains('://')) {
    raw = 'http://$raw';
  }
  final uri = Uri.parse(raw);
  if (uri.host.isEmpty) {
    throw FormatException('Invalid server url: "$serverUrl"');
  }
  return uri.replace(path: _joinPath(uri.path, endpointPath));
}

String _joinPath(String basePath, String endpointPath) {
  final base = basePath.endsWith('/')
      ? basePath.substring(0, basePath.length - 1)
      : basePath;
  return '$base$endpointPath';
}

Map<String, dynamic> _jobClaimRequestJson(oto.JobClaimRequest request) => {
  'runner_id': request.runnerId,
  'job_id': request.jobId,
  'execution_id': request.executionId,
};

Map<String, dynamic> _executionReportRequestJson(
  oto.ExecutionReportRequest request,
  BuildResult result,
) {
  final resultJson = result.toJson();
  return {
    'runner_id': request.runnerId,
    'job_id': request.jobId,
    'execution_id': request.executionId,
    'success': request.success,
    'exit_code': request.exitCode,
    'message': request.message,
    'step_events': resultJson['stepEvents'],
    'execution_result': resultJson,
  };
}

Map<String, dynamic> _logAppendRequestJson(oto.LogAppendRequest request) => {
  'runner_id': request.runnerId,
  'execution_id': request.executionId,
  'line': request.line,
};

Map<String, dynamic> _artifactReportRequestJson(
  oto.ArtifactReportRequest request,
) => {
  'runner_id': request.runnerId,
  'execution_id': request.executionId,
  'name': request.name,
  'path': request.path,
};

oto.StepEventReport _stepEventReport(StepEvent event) {
  final report = oto.StepEventReport()
    ..stepId = event.stepId
    ..workflowIndex = event.workflowIndex
    ..stepType = event.stepType
    ..event = event.event
    ..timestamp = event.timestamp
    ..commandId = event.commandId ?? ''
    ..commandType = event.commandType ?? '';
  if (event.error != null) {
    report.error.addAll(event.error!);
  }
  return report;
}

String? _emptyToNull(String value) => value.isEmpty ? null : value;

/// Parses a claim response `run_request` payload (snake_case or camelCase) into
/// an [oto.RunRequest]. Returns null when the server omitted the run request.
oto.RunRequest? _parseRunRequest(dynamic raw) {
  if (raw is! Map) {
    return null;
  }
  final request = oto.RunRequest();
  final runnerId = (raw['runner_id'] ?? raw['runnerId'] ?? '').toString();
  if (runnerId.isNotEmpty) request.runnerId = runnerId;
  final jobId = (raw['job_id'] ?? raw['jobId'] ?? '').toString();
  if (jobId.isNotEmpty) request.jobId = jobId;
  final executionId = (raw['execution_id'] ?? raw['executionId'] ?? '')
      .toString();
  if (executionId.isNotEmpty) request.executionId = executionId;
  final pipelineYamlPath =
      (raw['pipeline_yaml_path'] ?? raw['pipelineYamlPath'] ?? '').toString();
  if (pipelineYamlPath.isNotEmpty) request.pipelineYamlPath = pipelineYamlPath;
  final pipelineYaml = (raw['pipeline_yaml'] ?? raw['pipelineYaml'] ?? '')
      .toString();
  if (pipelineYaml.isNotEmpty) request.pipelineYaml = pipelineYaml;
  final variables = raw['variables'];
  if (variables is Map) {
    variables.forEach((key, value) {
      request.variables[key.toString()] = value?.toString() ?? '';
    });
  }
  final commandTypes = raw['command_types'] ?? raw['commandTypes'];
  if (commandTypes is List) {
    request.commandTypes.addAll(commandTypes.map((e) => e.toString()));
  }
  return request;
}
