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

import 'package:http/http.dart' as http;
import 'package:oto_console/oto_console.dart';

class OtoHttpCoreConnectionClient implements OtoCoreConnectionClient {
  final http.Client _httpClient;
  final Duration timeout;

  OtoHttpCoreConnectionClient({
    http.Client? httpClient,
    this.timeout = const Duration(seconds: 2),
  }) : _httpClient = httpClient ?? http.Client();

  @override
  Future<OtoCoreConnectionSnapshot> check(OtoConsoleConfig config) async {
    final checks = await Future.wait([
      _checkEndpoint(config.serverHttpUrl, 'Health', '/healthz'),
      _checkEndpoint(config.serverHttpUrl, 'Readiness', '/readyz'),
    ]);
    final health = checks[0];
    final readiness = checks[1];
    final state = _stateFor(health, readiness);

    return OtoCoreConnectionSnapshot(
      state: state,
      health: health,
      readiness: readiness,
      checkedAt: DateTime.now(),
      policyNote: 'CORS headers or a same-origin proxy are required.',
    );
  }

  Future<OtoCoreEndpointStatus> _checkEndpoint(
    String baseUrl,
    String label,
    String path,
  ) async {
    try {
      final uri = _endpointUri(baseUrl, path);
      final response = await _httpClient.get(uri).timeout(timeout);
      final body = response.body.trim();
      final ok = response.statusCode == 200 && body == 'OK';
      return OtoCoreEndpointStatus(
        label: label,
        path: path,
        state: ok
            ? OtoCoreConnectionState.online
            : OtoCoreConnectionState.offline,
        statusCode: response.statusCode,
        message: ok ? 'OK' : 'HTTP ${response.statusCode}: $body',
      );
    } on TimeoutException {
      return OtoCoreEndpointStatus(
        label: label,
        path: path,
        state: OtoCoreConnectionState.offline,
        message: 'Timed out',
      );
    } on FormatException catch (error) {
      return OtoCoreEndpointStatus(
        label: label,
        path: path,
        state: OtoCoreConnectionState.offline,
        message: error.message,
      );
    } on Exception catch (error) {
      return OtoCoreEndpointStatus(
        label: label,
        path: path,
        state: OtoCoreConnectionState.offline,
        message: error.toString(),
      );
    }
  }

  Uri _endpointUri(String baseUrl, String path) {
    final base = Uri.parse(baseUrl);
    if (!base.hasScheme || base.host.isEmpty) {
      throw const FormatException('Invalid OTO server HTTP URL');
    }
    return base.replace(path: path, query: null, fragment: null);
  }

  OtoCoreConnectionState _stateFor(
    OtoCoreEndpointStatus health,
    OtoCoreEndpointStatus readiness,
  ) {
    if (health.ok && readiness.ok) {
      return OtoCoreConnectionState.online;
    }
    if (health.ok || readiness.ok) {
      return OtoCoreConnectionState.degraded;
    }
    return OtoCoreConnectionState.offline;
  }
}

enum OtoCoreReadState { loading, empty, data, error }

enum OtoCoreWriteState { idle, submitting, succeeded, deferred, failed }

class OtoCoreWriteResult<T> {
  final OtoCoreWriteState state;
  final T? data;
  final String message;
  final int? statusCode;

  const OtoCoreWriteResult._({
    required this.state,
    this.data,
    required this.message,
    this.statusCode,
  });

  const OtoCoreWriteResult.idle({String message = 'Idle'})
    : this._(state: OtoCoreWriteState.idle, message: message);

  const OtoCoreWriteResult.submitting({String message = 'Submitting'})
    : this._(state: OtoCoreWriteState.submitting, message: message);

  const OtoCoreWriteResult.succeeded(
    T data, {
    String message = 'Created',
    int? statusCode,
  }) : this._(
         state: OtoCoreWriteState.succeeded,
         data: data,
         message: message,
         statusCode: statusCode,
       );

  const OtoCoreWriteResult.deferred({
    String message = 'Deferred',
    int? statusCode,
  }) : this._(
         state: OtoCoreWriteState.deferred,
         message: message,
         statusCode: statusCode,
       );

  const OtoCoreWriteResult.failed({required String message, int? statusCode})
    : this._(
        state: OtoCoreWriteState.failed,
        message: message,
        statusCode: statusCode,
      );

  bool get isSuccess => state == OtoCoreWriteState.succeeded && data != null;
  bool get isFailed => state == OtoCoreWriteState.failed;
  bool get isSubmitting => state == OtoCoreWriteState.submitting;
}

class OtoCoreReadResult<T> {
  final OtoCoreReadState state;
  final T? data;
  final String message;
  final int? statusCode;

  const OtoCoreReadResult._({
    required this.state,
    this.data,
    required this.message,
    this.statusCode,
  });

  const OtoCoreReadResult.loading({String message = 'Loading'})
    : this._(state: OtoCoreReadState.loading, message: message);

  const OtoCoreReadResult.empty({
    T? data,
    String message = 'No data',
    int? statusCode,
  }) : this._(
         state: OtoCoreReadState.empty,
         data: data,
         message: message,
         statusCode: statusCode,
       );

  const OtoCoreReadResult.data(T data, {String message = 'OK', int? statusCode})
    : this._(
        state: OtoCoreReadState.data,
        data: data,
        message: message,
        statusCode: statusCode,
      );

  const OtoCoreReadResult.error(String message, {int? statusCode})
    : this._(
        state: OtoCoreReadState.error,
        message: message,
        statusCode: statusCode,
      );

  bool get hasData => state == OtoCoreReadState.data && data != null;
}

abstract class OtoCoreReadClient {
  Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
    OtoConsoleConfig config,
    String runnerID,
  );

  Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
    OtoConsoleConfig config,
    String runnerID,
  );

  Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
    OtoConsoleConfig config,
    String jobID,
  );

  Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
    OtoConsoleConfig config,
    String executionID,
  );

  Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
    OtoConsoleConfig config,
    String executionID,
  );

  Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
    OtoConsoleConfig config,
    String executionID,
  );
}

abstract class OtoCoreWriteClient {
  Future<OtoCoreWriteResult<OtoJobRecord>> createJob(
    OtoConsoleConfig config,
    OtoJobCreateDraft draft,
  );

  Future<OtoCoreWriteResult<void>> cancelExecution(
    OtoConsoleConfig config,
    OtoExecutionCancelDraft draft,
  );

  Future<OtoCoreWriteResult<void>> reportExecution(
    OtoConsoleConfig config,
    OtoExecutionReportDraft draft,
  );

  Future<OtoCoreWriteResult<void>> appendExecutionLog(
    OtoConsoleConfig config,
    OtoExecutionLogDraft draft,
  );

  Future<OtoCoreWriteResult<void>> appendExecutionArtifact(
    OtoConsoleConfig config,
    OtoExecutionArtifactDraft draft,
  );

  Future<OtoCoreWriteResult<void>> selfUpdateRunner(
    OtoConsoleConfig config,
    OtoRunnerSelfUpdateDraft draft,
  );
}

class OtoHttpCoreWriteClient implements OtoCoreWriteClient {
  final http.Client _httpClient;
  final Duration timeout;

  OtoHttpCoreWriteClient({
    http.Client? httpClient,
    this.timeout = const Duration(seconds: 2),
  }) : _httpClient = httpClient ?? http.Client();

  @override
  Future<OtoCoreWriteResult<OtoJobRecord>> createJob(
    OtoConsoleConfig config,
    OtoJobCreateDraft draft,
  ) async {
    Uri uri;
    try {
      uri = _endpointUri(config.serverHttpUrl, '/api/v1/jobs');
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      if (response.statusCode == 201) {
        final decoded = jsonDecode(response.body);
        if (decoded is! Map<String, dynamic>) {
          return const OtoCoreWriteResult.failed(
            message: 'Expected JSON object for created job',
          );
        }
        return OtoCoreWriteResult.succeeded(
          OtoJobRecord.fromJson(decoded),
          statusCode: response.statusCode,
        );
      }

      if (response.statusCode == 400) {
        return OtoCoreWriteResult.failed(
          message: _extractErrorMessage(response.body, 'Invalid request body'),
          statusCode: response.statusCode,
        );
      }

      if (response.statusCode == 409) {
        return OtoCoreWriteResult.failed(
          message: _extractErrorMessage(response.body, 'Job ID already exists'),
          statusCode: response.statusCode,
        );
      }

      if (response.statusCode >= 400 && response.statusCode < 500) {
        return OtoCoreWriteResult.failed(
          message: _extractErrorMessage(
            response.body,
            'HTTP ${response.statusCode}',
          ),
          statusCode: response.statusCode,
        );
      }

      if (response.statusCode >= 500) {
        return OtoCoreWriteResult.failed(
          message: 'Server error',
          statusCode: response.statusCode,
        );
      }

      return OtoCoreWriteResult.failed(
        message:
            'Unexpected HTTP ${response.statusCode}: ${response.body.trim()}',
        statusCode: response.statusCode,
      );
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(message: 'Timed out');
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  @override
  Future<OtoCoreWriteResult<void>> cancelExecution(
    OtoConsoleConfig config,
    OtoExecutionCancelDraft draft,
  ) async {
    final runnerID = (draft.runnerID ?? '').trim();
    final execID = draft.executionID;
    Uri uri;
    if (runnerID.isEmpty) {
      return const OtoCoreWriteResult.failed(message: 'Runner ID is required');
    }
    try {
      uri = _endpointUri(
        config.serverHttpUrl,
        '/api/v1/runners/$runnerID/executions/$execID/cancel',
      );
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      return _parseExecutionActionResponse(response, 'cancel');
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(message: 'Cancel timed out');
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  @override
  Future<OtoCoreWriteResult<void>> reportExecution(
    OtoConsoleConfig config,
    OtoExecutionReportDraft draft,
  ) async {
    final runnerID = (draft.runnerID ?? '').trim();
    final execID = draft.executionID;
    Uri uri;
    if (runnerID.isEmpty) {
      return const OtoCoreWriteResult.failed(message: 'Runner ID is required');
    }
    try {
      uri = _endpointUri(
        config.serverHttpUrl,
        '/api/v1/runners/$runnerID/executions/$execID/report',
      );
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      return _parseExecutionActionResponse(response, 'report');
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(message: 'Report timed out');
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  @override
  Future<OtoCoreWriteResult<void>> appendExecutionLog(
    OtoConsoleConfig config,
    OtoExecutionLogDraft draft,
  ) async {
    final runnerID = draft.runnerID ?? '';
    final execID = draft.executionID;
    Uri uri;
    try {
      if (runnerID.isNotEmpty) {
        uri = _endpointUri(
          config.serverHttpUrl,
          '/api/v1/runners/$runnerID/executions/$execID/logs',
        );
      } else {
        uri = _endpointUri(
          config.serverHttpUrl,
          '/api/v1/executions/$execID/logs',
        );
      }
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      return _parseExecutionActionResponse(response, 'log');
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(message: 'Log append timed out');
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  @override
  Future<OtoCoreWriteResult<void>> appendExecutionArtifact(
    OtoConsoleConfig config,
    OtoExecutionArtifactDraft draft,
  ) async {
    final runnerID = draft.runnerID ?? '';
    final execID = draft.executionID;
    Uri uri;
    try {
      if (runnerID.isNotEmpty) {
        uri = _endpointUri(
          config.serverHttpUrl,
          '/api/v1/runners/$runnerID/executions/$execID/artifacts',
        );
      } else {
        uri = _endpointUri(
          config.serverHttpUrl,
          '/api/v1/executions/$execID/artifacts',
        );
      }
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      return _parseExecutionActionResponse(response, 'artifact');
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(
        message: 'Artifact report timed out',
      );
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  @override
  Future<OtoCoreWriteResult<void>> selfUpdateRunner(
    OtoConsoleConfig config,
    OtoRunnerSelfUpdateDraft draft,
  ) async {
    Uri uri;
    try {
      uri = _endpointUri(
        config.serverHttpUrl,
        '/api/v1/runners/${draft.runnerID}/self-update',
      );
    } on FormatException catch (_) {
      return const OtoCoreWriteResult.failed(message: 'Invalid server URL');
    }

    try {
      final response = await _httpClient
          .post(
            uri,
            headers: const {'content-type': 'application/json'},
            body: jsonEncode(draft.toJson()),
          )
          .timeout(timeout);

      if (response.statusCode >= 400) {
        return OtoCoreWriteResult.failed(
          message: _extractErrorMessage(
            response.body,
            'HTTP ${response.statusCode}',
          ),
          statusCode: response.statusCode,
        );
      }

      if (response.statusCode >= 200 && response.statusCode < 300) {
        try {
          final decoded = jsonDecode(response.body);
          if (decoded is! Map<String, dynamic>) {
            return const OtoCoreWriteResult.failed(
              message: 'Expected JSON response for self-update',
            );
          }

          final errorMessage = _string(decoded, 'error_message');
          if (errorMessage.isNotEmpty) {
            return OtoCoreWriteResult.failed(
              message: errorMessage,
              statusCode: response.statusCode,
            );
          }

          if (decoded['deferred'] == true) {
            final msg = _string(decoded, 'message');
            return OtoCoreWriteResult.deferred(
              message: msg.isNotEmpty ? msg : 'Update deferred',
              statusCode: response.statusCode,
            );
          }

          if (decoded['accepted'] == true || decoded['success'] == true) {
            return OtoCoreWriteResult.succeeded(
              null,
              message: 'Update accepted',
              statusCode: response.statusCode,
            );
          }

          return OtoCoreWriteResult.failed(
            message: 'Update rejected',
            statusCode: response.statusCode,
          );
        } on FormatException catch (error) {
          return OtoCoreWriteResult.failed(message: error.message);
        }
      }

      return OtoCoreWriteResult.failed(
        message:
            'Unexpected HTTP ${response.statusCode}: ${response.body.trim()}',
        statusCode: response.statusCode,
      );
    } on TimeoutException {
      return const OtoCoreWriteResult.failed(message: 'Timed out');
    } on FormatException catch (error) {
      return OtoCoreWriteResult.failed(message: error.message);
    } on Exception catch (error) {
      return OtoCoreWriteResult.failed(message: error.toString());
    }
  }

  /// Parse execution action response (cancel, report, log, artifact).
  ///
  /// Go handlers return:
  /// - Cancel: `{"success": true/false, "error_message": "..."}`
  /// - Report/Log/Artifact: `{"accepted": true/false, "error_message": "..."}`
  /// HTTP 2xx returns the JSON body. HTTP 4xx/5xx returns error.
  OtoCoreWriteResult<void> _parseExecutionActionResponse(
    http.Response response,
    String actionType,
  ) {
    final body = response.body.trim();

    if (response.statusCode >= 400) {
      final message = _extractErrorMessage(
        response.body,
        'HTTP ${response.statusCode}',
      );
      if (response.statusCode == 400) {
        return OtoCoreWriteResult.failed(
          message: message,
          statusCode: response.statusCode,
        );
      }
      if (response.statusCode == 404) {
        return OtoCoreWriteResult.failed(
          message: message,
          statusCode: response.statusCode,
        );
      }
      return OtoCoreWriteResult.failed(
        message: message,
        statusCode: response.statusCode,
      );
    }

    // Accept 2xx (200 OK, 201 Created)
    if (response.statusCode >= 200 && response.statusCode < 300) {
      try {
        final decoded = jsonDecode(body);
        if (decoded is! Map<String, dynamic>) {
          return OtoCoreWriteResult.failed(
            message: 'Expected JSON response for $actionType',
          );
        }

        // Check for `success` field (cancel response) or `accepted` field (report/log/artifact)
        final isSuccess =
            decoded['success'] == true || decoded['accepted'] == true;
        final errorMessage = _string(decoded, 'error_message');

        if (isSuccess && errorMessage.isEmpty) {
          return OtoCoreWriteResult.succeeded(
            null,
            message: '$actionType accepted',
            statusCode: response.statusCode,
          );
        }

        // accepted=false or success=false with error_message -> failed
        if (errorMessage.isNotEmpty) {
          return OtoCoreWriteResult.failed(
            message: errorMessage,
            statusCode: response.statusCode,
          );
        }

        // accepted=false without error_message
        final accepted = decoded['accepted'] as bool?;
        final success = decoded['success'] as bool?;
        if (accepted == false || success == false) {
          return OtoCoreWriteResult.failed(
            message: '$actionType rejected',
            statusCode: response.statusCode,
          );
        }

        return OtoCoreWriteResult.succeeded(
          null,
          message: '$actionType succeeded',
          statusCode: response.statusCode,
        );
      } on FormatException catch (error) {
        return OtoCoreWriteResult.failed(message: error.message);
      }
    }

    return OtoCoreWriteResult.failed(
      message: 'Unexpected HTTP ${response.statusCode}: $body',
      statusCode: response.statusCode,
    );
  }

  String _extractErrorMessage(String body, String defaultValue) {
    try {
      final decoded = jsonDecode(body);
      if (decoded is Map<String, dynamic>) {
        // Execution action responses use error_message field
        var message =
            decoded['error_message'] ??
            decoded['message'] ??
            decoded['error'] ??
            '';
        if (message is String && message.isNotEmpty) {
          return message;
        }
      }
    } on Exception {
      // Fall through to default
    }
    return defaultValue;
  }
}

class OtoHttpCoreReadClient implements OtoCoreReadClient {
  final http.Client _httpClient;
  final Duration timeout;

  OtoHttpCoreReadClient({
    http.Client? httpClient,
    this.timeout = const Duration(seconds: 2),
  }) : _httpClient = httpClient ?? http.Client();

  @override
  Future<OtoCoreReadResult<OtoRunnerRecord>> fetchRunner(
    OtoConsoleConfig config,
    String runnerID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/runners/$runnerID',
      emptyMessage: 'Runner not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      return OtoCoreReadResult.data(
        OtoRunnerRecord.fromJson(result.data!),
        statusCode: result.statusCode,
      );
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  @override
  Future<OtoCoreReadResult<OtoRunnerStatus>> fetchRunnerStatus(
    OtoConsoleConfig config,
    String runnerID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/runners/$runnerID/status',
      emptyMessage: 'Runner status not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      return OtoCoreReadResult.data(
        OtoRunnerStatus.fromJson(result.data!),
        statusCode: result.statusCode,
      );
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  @override
  Future<OtoCoreReadResult<OtoJobRecord>> fetchJob(
    OtoConsoleConfig config,
    String jobID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/jobs/$jobID',
      emptyMessage: 'Job not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      return OtoCoreReadResult.data(
        OtoJobRecord.fromJson(result.data!),
        statusCode: result.statusCode,
      );
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  @override
  Future<OtoCoreReadResult<OtoExecutionRecord>> fetchExecution(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/executions/$executionID',
      emptyMessage: 'Execution not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      return OtoCoreReadResult.data(
        OtoExecutionRecord.fromJson(result.data!),
        statusCode: result.statusCode,
      );
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  @override
  Future<OtoCoreReadResult<List<OtoExecutionLogEntry>>> fetchLogs(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/executions/$executionID/logs',
      emptyMessage: 'Execution logs not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      final logs = _jsonList(
        result.data!,
        'logs',
      ).map(OtoExecutionLogEntry.fromJson).toList(growable: false);
      if (logs.isEmpty) {
        return OtoCoreReadResult.empty(
          data: logs,
          message: 'No logs',
          statusCode: result.statusCode,
        );
      }
      return OtoCoreReadResult.data(logs, statusCode: result.statusCode);
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  @override
  Future<OtoCoreReadResult<List<OtoArtifactRecord>>> fetchArtifacts(
    OtoConsoleConfig config,
    String executionID,
  ) async {
    final result = await _getObject(
      config,
      '/api/v1/executions/$executionID/artifacts',
      emptyMessage: 'Execution artifacts not found',
    );
    if (!result.hasData) {
      return _forwardState(result);
    }
    try {
      final artifacts = _jsonList(
        result.data!,
        'artifacts',
      ).map(OtoArtifactRecord.fromJson).toList(growable: false);
      if (artifacts.isEmpty) {
        return OtoCoreReadResult.empty(
          data: artifacts,
          message: 'No artifacts',
          statusCode: result.statusCode,
        );
      }
      return OtoCoreReadResult.data(artifacts, statusCode: result.statusCode);
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    }
  }

  Future<OtoCoreReadResult<Map<String, dynamic>>> _getObject(
    OtoConsoleConfig config,
    String path, {
    required String emptyMessage,
  }) async {
    try {
      final response = await _httpClient
          .get(_endpointUri(config.serverHttpUrl, path))
          .timeout(timeout);
      if (response.statusCode == 404) {
        return OtoCoreReadResult.empty(
          message: emptyMessage,
          statusCode: response.statusCode,
        );
      }
      if (response.statusCode < 200 || response.statusCode >= 300) {
        return OtoCoreReadResult.error(
          'HTTP ${response.statusCode}: ${response.body.trim()}',
          statusCode: response.statusCode,
        );
      }
      final decoded = jsonDecode(response.body);
      if (decoded is! Map) {
        return const OtoCoreReadResult.error('Expected JSON object');
      }
      return OtoCoreReadResult.data(
        Map<String, dynamic>.from(decoded),
        statusCode: response.statusCode,
      );
    } on TimeoutException {
      return const OtoCoreReadResult.error('Timed out');
    } on FormatException catch (error) {
      return OtoCoreReadResult.error(error.message);
    } on Exception catch (error) {
      return OtoCoreReadResult.error(error.toString());
    }
  }
}

class OtoRunnerRecord {
  final String runnerID;
  final String alias;
  final String protocolVersion;
  final String status;
  final DateTime? acceptedAt;
  final DateTime? firstHeartbeatAt;
  final DateTime? lastHeartbeatAt;
  final String failureReason;

  const OtoRunnerRecord({
    required this.runnerID,
    required this.alias,
    required this.protocolVersion,
    required this.status,
    required this.acceptedAt,
    required this.firstHeartbeatAt,
    required this.lastHeartbeatAt,
    required this.failureReason,
  });

  factory OtoRunnerRecord.fromJson(Map<String, dynamic> json) {
    final runnerID = _requiredString(json, 'runner_id');
    return OtoRunnerRecord(
      runnerID: runnerID,
      alias: _string(json, 'alias'),
      protocolVersion: _string(json, 'protocol_version'),
      status: _string(json, 'status'),
      acceptedAt: _dateTime(json, 'accepted_at'),
      firstHeartbeatAt: _dateTime(json, 'first_heartbeat_at'),
      lastHeartbeatAt: _dateTime(json, 'last_heartbeat_at'),
      failureReason: _string(json, 'failure_reason'),
    );
  }
}

class OtoRunnerStatus {
  final bool accepted;
  final String runnerID;
  final String status;
  final String currentJobID;
  final String currentExecutionID;
  final String message;

  const OtoRunnerStatus({
    required this.accepted,
    required this.runnerID,
    required this.status,
    required this.currentJobID,
    required this.currentExecutionID,
    required this.message,
  });

  factory OtoRunnerStatus.fromJson(Map<String, dynamic> json) {
    return OtoRunnerStatus(
      accepted: json['accepted'] == true,
      runnerID: _string(json, 'runner_id'),
      status: _string(json, 'status'),
      currentJobID: _string(json, 'current_job_id'),
      currentExecutionID: _string(json, 'current_execution_id'),
      message: _string(json, 'message'),
    );
  }
}

class OtoJobRecord {
  final String id;
  final String name;
  final String state;
  final DateTime? createdAt;
  final DateTime? updatedAt;
  final String executionID;
  final Map<String, dynamic>? runRequest;

  const OtoJobRecord({
    required this.id,
    required this.name,
    required this.state,
    required this.createdAt,
    required this.updatedAt,
    required this.executionID,
    required this.runRequest,
  });

  factory OtoJobRecord.fromJson(Map<String, dynamic> json) {
    return OtoJobRecord(
      id: _requiredString(json, 'id'),
      name: _string(json, 'name'),
      state: _string(json, 'state'),
      createdAt: _dateTime(json, 'created_at'),
      updatedAt: _dateTime(json, 'updated_at'),
      executionID: _string(json, 'execution_id'),
      runRequest: _optionalObject(json, 'run_request'),
    );
  }
}

class OtoExecutionRecord {
  final String id;
  final String jobID;
  final String state;
  final DateTime? createdAt;
  final DateTime? updatedAt;
  final String executionID;

  const OtoExecutionRecord({
    required this.id,
    required this.jobID,
    required this.state,
    required this.createdAt,
    required this.updatedAt,
    required this.executionID,
  });

  factory OtoExecutionRecord.fromJson(Map<String, dynamic> json) {
    return OtoExecutionRecord(
      id: _requiredString(json, 'id'),
      jobID: _string(json, 'job_id'),
      state: _string(json, 'state'),
      createdAt: _dateTime(json, 'created_at'),
      updatedAt: _dateTime(json, 'updated_at'),
      executionID: _string(json, 'execution_id'),
    );
  }
}

class OtoExecutionLogEntry {
  final DateTime? timestamp;
  final String line;

  const OtoExecutionLogEntry({required this.timestamp, required this.line});

  factory OtoExecutionLogEntry.fromJson(Map<String, dynamic> json) {
    return OtoExecutionLogEntry(
      timestamp: _dateTime(json, 'Timestamp') ?? _dateTime(json, 'timestamp'),
      line: _string(json, 'Line').isNotEmpty
          ? _string(json, 'Line')
          : _string(json, 'line'),
    );
  }
}

class OtoArtifactRecord {
  final String name;
  final String path;

  const OtoArtifactRecord({required this.name, required this.path});

  factory OtoArtifactRecord.fromJson(Map<String, dynamic> json) {
    return OtoArtifactRecord(
      name: _string(json, 'Name').isNotEmpty
          ? _string(json, 'Name')
          : _string(json, 'name'),
      path: _string(json, 'Path').isNotEmpty
          ? _string(json, 'Path')
          : _string(json, 'path'),
    );
  }
}

OtoCoreReadResult<T> _forwardState<T>(
  OtoCoreReadResult<Map<String, dynamic>> result,
) {
  return switch (result.state) {
    OtoCoreReadState.loading => OtoCoreReadResult.loading(
      message: result.message,
    ),
    OtoCoreReadState.empty => OtoCoreReadResult.empty(
      message: result.message,
      statusCode: result.statusCode,
    ),
    OtoCoreReadState.error => OtoCoreReadResult.error(
      result.message,
      statusCode: result.statusCode,
    ),
    OtoCoreReadState.data => OtoCoreReadResult.error(result.message),
  };
}

Uri _endpointUri(String baseUrl, String path) {
  final base = Uri.parse(baseUrl);
  if (!base.hasScheme || base.host.isEmpty) {
    throw const FormatException('Invalid OTO server HTTP URL');
  }
  return base.replace(path: path, query: null, fragment: null);
}

String _requiredString(Map<String, dynamic> json, String key) {
  final value = _string(json, key);
  if (value.isEmpty) {
    throw FormatException('Missing required field: $key');
  }
  return value;
}

String _string(Map<String, dynamic> json, String key) {
  final value = json[key];
  return value == null ? '' : value.toString();
}

DateTime? _dateTime(Map<String, dynamic> json, String key) {
  final value = _string(json, key);
  if (value.isEmpty) {
    return null;
  }
  return DateTime.tryParse(value);
}

Map<String, dynamic>? _optionalObject(Map<String, dynamic> json, String key) {
  final value = json[key];
  if (value == null) {
    return null;
  }
  if (value is! Map) {
    throw FormatException('Expected object field: $key');
  }
  return Map<String, dynamic>.from(value);
}

List<Map<String, dynamic>> _jsonList(Map<String, dynamic> json, String key) {
  final value = json[key];
  if (value is! List) {
    throw FormatException('Expected array field: $key');
  }
  return value
      .map((item) {
        if (item is! Map) {
          throw FormatException('Expected object item in: $key');
        }
        return Map<String, dynamic>.from(item);
      })
      .toList(growable: false);
}
