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

import 'package:oto/oto/agent/oto_server_job_client.dart';
import 'package:oto/oto/application.dart';
import 'package:oto/oto/agent/oto/runner.pb.dart' as oto;
import 'package:oto/oto/core/build_result.dart';
import 'package:path/path.dart' as path;

/// Result of parsing and preparing a [oto.RunRequest] for execution.
class RemoteRunInput {
  final String yamlContent;
  final Map<String, String> variables;

  const RemoteRunInput({required this.yamlContent, required this.variables});
}

/// Validates and interprets a [oto.RunRequest] into a usable [RemoteRunInput].
///
/// Rules:
/// - [oto.RunRequest.pipelineYaml] (inline YAML) takes priority if non-empty.
/// - [oto.RunRequest.pipelineYamlPath] must be an absolute path under
///   [workspaceRoot]; path traversal is rejected.
/// - [variables] are merged as pipeline property overrides.
class RunRequestParser {
  final String workspaceRoot;

  const RunRequestParser({required this.workspaceRoot});

  RemoteRunInput parse(oto.RunRequest request) {
    final yamlPath = request.pipelineYamlPath;
    final yamlInline = request.pipelineYaml;

    if (yamlInline.isNotEmpty) {
      // Validate inline YAML is parseable (best-effort: just check it's not empty)
      if (yamlInline.trim().isEmpty) {
        throw ArgumentError('RunRequest has empty pipelineYaml and no path.');
      }
      return RemoteRunInput(
        yamlContent: yamlInline,
        variables: _toMap(request.variables),
      );
    }

    if (yamlPath.isNotEmpty) {
      final resolved = _resolvePathConfinement(yamlPath);
      final content = File(resolved).readAsStringSync();
      return RemoteRunInput(
        yamlContent: content,
        variables: _toMap(request.variables),
      );
    }

    throw ArgumentError(
      'RunRequest has neither pipelineYaml nor pipelineYamlPath.',
    );
  }

  String _resolvePathConfinement(String rawPath) {
    return resolveWithinWorkspace(
      rawPath,
      workspaceRoot,
      label: 'pipelineYamlPath',
    );
  }

  Map<String, String> _toMap(Map<String, String> fields) {
    final result = <String, String>{};
    fields.forEach((k, v) => result[k] = v);
    return result;
  }
}

/// Resolves [rawPath] against [workspaceRoot] and rejects any path that escapes
/// the workspace. Relative paths resolve under the root; absolute paths must
/// stay confined. [label] is used in the error message.
///
/// `path.equals`/`path.isWithin` correctly reject sibling-prefix attacks such as
/// `/var/workspace2` against a root of `/var/workspace`.
String resolveWithinWorkspace(
  String rawPath,
  String workspaceRoot, {
  required String label,
}) {
  final canonicalRoot = path.canonicalize(workspaceRoot);
  final absolute = path.canonicalize(
    path.isAbsolute(rawPath) ? rawPath : path.join(canonicalRoot, rawPath),
  );
  if (!path.equals(canonicalRoot, absolute) &&
      !path.isWithin(canonicalRoot, absolute)) {
    throw ArgumentError(
      '$label escapes workspace: "$rawPath" resolved to "$absolute"',
    );
  }
  return absolute;
}

/// Executor that runs a [RemoteRunInput] through [Application.build] and
/// reports results back to the OTO Server via [jobClient].
///
/// Provides [runOnce] for testable single-iteration execution without an
/// infinite loop.
class RemoteRunExecutor {
  final RunRequestParser _parser;
  final RemoteJobReporter _jobClient;
  final void Function(String)? _onLog;
  final Duration pollInterval;

  RemoteRunExecutor({
    required RunRequestParser parser,
    required RemoteJobReporter jobClient,
    void Function(String)? onLog,
    this.pollInterval = const Duration(seconds: 2),
  }) : _parser = parser,
       _jobClient = jobClient,
       _onLog = onLog;

  /// Executes a single job identified by [jobId] and [executionId].
  ///
  /// Flow:
  /// 1. Parse the [RunRequest] – if this fails, a failure report is sent.
  /// 2. Log "run started" to server via [RemoteJobReporter.appendLog].
  /// 3. Run [Application.build] with the resolved YAML + merged variables.
  /// 4. Always call [RemoteJobReporter.reportExecution] with the result
  ///    (success or failure), including failures from steps 1–3.
  ///
  /// Returns the [BuildResult] regardless of success or failure.
  Future<BuildResult> runOnce({
    required String jobId,
    required String executionId,
    required oto.RunRequest runRequest,
  }) async {
    BuildResult result;
    try {
      final input = _parser.parse(runRequest);
      // Merge remote variables into the YAML `property` map.  The merge parses
      // the YAML into a structured map and overlays the variables, so any valid
      // property form (block map, inline map, or none) stays valid afterwards.
      final yamlContent = mergeVariablesIntoYaml(
        input.yamlContent,
        input.variables,
      );

      _log('Executing job: $jobId (execution: $executionId)');

      // Send start log line.
      try {
        await _jobClient.appendLog(
          executionId: executionId,
          line: 'run started for job $jobId',
        );
      } catch (e) {
        _log('Failed to send start log: $e');
        // Continue execution even if log append fails.
      }

      // Artifact declarations are interpreted before the build so an invalid
      // declaration fails the run instead of being silently dropped. Valid
      // declarations are threaded into the result so they survive both success
      // and failure and are reported back to the server.
      final artifacts = parseArtifactDeclarations(
        yamlContent,
        workspaceRoot: _parser.workspaceRoot,
      );

      result = await Application.instance.build(
        BuildType.file,
        yamlContent: yamlContent,
        logEnable: true,
        artifacts: artifacts,
      );
    } catch (e, stackTrace) {
      // parse / file-read / build errors must also produce a failure report so
      // the server can transition the job to failed state.
      result = BuildResult.failure(e, stackTrace);
    }

    // Always report the execution result.
    try {
      _log('Reporting execution result: success=${result.success}');
      await _jobClient.reportExecution(
        jobId: jobId,
        executionId: executionId,
        result: result,
      );
    } catch (e) {
      _log('Failed to report execution: $e');
      // Even if report fails, return the build result so the caller knows.
    }

    // Report declared artifacts after the execution report. The execution
    // report has already been sent, so an artifact-report failure cannot be
    // re-reported as an execution failure; it is surfaced through a distinct
    // log line instead of masking it as part of the execution result.
    if (result.artifacts.isNotEmpty) {
      try {
        _log('Reporting ${result.artifacts.length} artifact(s)');
        await _jobClient.reportArtifacts(
          executionId: executionId,
          artifacts: result.artifacts,
        );
      } catch (e) {
        _log('Failed to report artifacts: $e');
      }
    }

    return result;
  }

  /// Parses artifact declarations from the build YAML `property.artifacts`.
  ///
  /// Each entry must be a map with a non-empty `name` and a non-empty `path`.
  /// `path` is resolved against [workspaceRoot]: relative paths resolve under
  /// the root and absolute paths must stay confined. Any invalid declaration
  /// throws [ArgumentError] so the run fails loudly rather than silently
  /// dropping the artifact. A missing `property.artifacts` yields an empty list.
  static List<BuildArtifact> parseArtifactDeclarations(
    String yamlContent, {
    required String workspaceRoot,
  }) {
    final root = Application.getMapFromYamlA(yamlContent);
    if (root == null) return const [];

    final property = root['property'];
    if (property is! Map) return const [];

    final declarations = property['artifacts'];
    if (declarations == null) return const [];
    if (declarations is! List) {
      throw ArgumentError(
        'property.artifacts must be a list of {name, path} maps.',
      );
    }

    final result = <BuildArtifact>[];
    for (var i = 0; i < declarations.length; i++) {
      final entry = declarations[i];
      if (entry is! Map) {
        throw ArgumentError(
          'property.artifacts[$i] must be a map with name and path.',
        );
      }
      final name = entry['name'];
      if (name is! String || name.trim().isEmpty) {
        throw ArgumentError(
          'property.artifacts[$i] "name" must be a non-empty string.',
        );
      }
      final declaredPath = entry['path'];
      if (declaredPath is! String || declaredPath.trim().isEmpty) {
        throw ArgumentError(
          'property.artifacts[$i] (name: "$name") "path" must be a non-empty string.',
        );
      }
      // Confine the declared path; the declared form is kept for reporting.
      resolveWithinWorkspace(
        declaredPath,
        workspaceRoot,
        label: 'property.artifacts[$i] path',
      );
      result.add(BuildArtifact(name: name, path: declaredPath));
    }
    return result;
  }

  /// Merges [variables] into the YAML `property` map and returns a build-ready
  /// document.
  ///
  /// The YAML is parsed into a structured map with the same parser
  /// [Application.build] uses ([Application.getMapFromYamlA]); [variables] are
  /// overlaid onto the existing `property` map (remote variables override YAML
  /// values), and the merged map is re-emitted as JSON — a valid subset of
  /// YAML that [Application.build] re-parses. Working on the parsed map keeps
  /// every valid property form correct: a block-style `property:` map, an
  /// inline `property: {…}` map, or no `property` section at all.
  ///
  /// If no [variables] are provided, the YAML does not parse into a map, or the
  /// existing `property` is present but not a map, the original [yamlContent] is
  /// returned unchanged so [Application.build] surfaces the original
  /// parse/validation error instead of having an invalid `property` silently
  /// replaced with a clean map (a validation bypass).
  static String mergeVariablesIntoYaml(
    String yamlContent,
    Map<String, String> variables,
  ) {
    if (variables.isEmpty) return yamlContent;

    final root = Application.getMapFromYamlA(yamlContent);
    if (root == null) return yamlContent;

    final existingProperty = root['property'];
    // A present-but-non-map `property` is invalid input. Leave the YAML
    // untouched so Application.build's `_validateBuildMap` rejects it, rather
    // than overwriting it with a valid map and bypassing that validation.
    if (existingProperty != null && existingProperty is! Map) {
      return yamlContent;
    }

    final mergedProperty = <String, dynamic>{};
    if (existingProperty is Map) {
      existingProperty.forEach((key, value) {
        mergedProperty['$key'] = value;
      });
    }
    // Remote variables always win over existing YAML property values.
    variables.forEach((key, value) {
      mergedProperty[key] = value;
    });

    root['property'] = mergedProperty;
    return jsonEncode(root);
  }

  void _log(String message) {
    if (_onLog != null) {
      _onLog(message);
    } else {
      print('[RemoteRunExecutor] $message');
    }
  }
}
