<?php

declare(strict_types=1);

namespace Drupal\flowdrop_workflow\Service;

/**
 * Default WorkflowSchemaDiffer implementation.
 *
 * Classification rules: tightening accepted values is major, loosening is
 * minor. See the workflow schema plan's versioning section for the full
 * table.
 *
 * @internal
 */
final class WorkflowSchemaDiffer implements WorkflowSchemaDifferInterface {

  /**
   * {@inheritdoc}
   */
  public function diff(
    ?array $previousParameterSchema,
    ?array $newParameterSchema,
    ?array $previousOutputSchema,
    ?array $newOutputSchema,
  ): array {
    $changes = [];
    $level = 'none';

    foreach ([
      ['parameter_schema', $previousParameterSchema, $newParameterSchema],
      ['output_schema', $previousOutputSchema, $newOutputSchema],
    ] as [$rootName, $previous, $new]) {
      $sideLevel = $this->diffSchema($rootName, $previous, $new, $changes);
      $level = $this->mergeLevel($level, $sideLevel);
    }

    return [
      'level' => $level,
      'changes' => $changes,
    ];
  }

  /**
   * Diffs two schemas at a given path.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed>|null $previous
   *   The previous schema.
   * @param array<string, mixed>|null $new
   *   The new schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffSchema(string $path, ?array $previous, ?array $new, array &$changes): string {
    if ($previous === NULL && $new === NULL) {
      return 'none';
    }
    if ($previous === NULL && $new !== NULL) {
      $changes[] = ['path' => $path, 'kind' => 'declared', 'before' => NULL, 'after' => $new];
      return 'minor';
    }
    if ($previous !== NULL && $new === NULL) {
      $changes[] = ['path' => $path, 'kind' => 'removed', 'before' => $previous, 'after' => NULL];
      return 'major';
    }

    $previousProperties = $this->normaliseProperties($previous['properties'] ?? []);
    $newProperties = $this->normaliseProperties($new['properties'] ?? []);

    $level = 'none';

    // Removed properties -> major.
    foreach (array_diff_key($previousProperties, $newProperties) as $name => $before) {
      $changes[] = [
        'path' => "$path.properties.$name",
        'kind' => 'property_removed',
        'before' => $before,
        'after' => NULL,
      ];
      $level = $this->mergeLevel($level, 'major');
    }

    // Added properties -> minor.
    foreach (array_diff_key($newProperties, $previousProperties) as $name => $after) {
      $changes[] = [
        'path' => "$path.properties.$name",
        'kind' => 'property_added',
        'before' => NULL,
        'after' => $after,
      ];
      $level = $this->mergeLevel($level, 'minor');
    }

    // Changed properties — drill in.
    foreach (array_intersect_key($previousProperties, $newProperties) as $name => $before) {
      $after = $newProperties[$name];
      if ($before === $after) {
        continue;
      }
      $propLevel = $this->diffProperty("$path.properties.$name", $before, $after, $changes);
      $level = $this->mergeLevel($level, $propLevel);
    }

    return $level;
  }

  /**
   * Diffs a single property and returns the change level.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffProperty(string $path, array $before, array $after, array &$changes): string {
    $level = 'none';

    // Type change -> major.
    $beforeType = $before['type'] ?? NULL;
    $afterType = $after['type'] ?? NULL;
    if ($beforeType !== $afterType) {
      $changes[] = ['path' => "$path.type", 'kind' => 'type_changed', 'before' => $beforeType, 'after' => $afterType];
      $level = $this->mergeLevel($level, 'major');
    }

    $level = $this->mergeLevel($level, $this->diffEnum($path, $before, $after, $changes));
    $level = $this->mergeLevel($level, $this->diffFormat($path, $before, $after, $changes));
    $level = $this->mergeLevel($level, $this->diffDefault($path, $before, $after, $changes));
    $level = $this->mergeLevel($level, $this->diffNumericBound($path, 'minimum', $before, $after, $changes, tighteningWhenRaised: TRUE));
    $level = $this->mergeLevel($level, $this->diffNumericBound($path, 'maximum', $before, $after, $changes, tighteningWhenRaised: FALSE));
    $level = $this->mergeLevel($level, $this->diffNumericBound($path, 'minLength', $before, $after, $changes, tighteningWhenRaised: TRUE));
    $level = $this->mergeLevel($level, $this->diffNumericBound($path, 'maxLength', $before, $after, $changes, tighteningWhenRaised: FALSE));
    $level = $this->mergeLevel($level, $this->diffPattern($path, $before, $after, $changes));

    // Recurse into nested properties / items.
    if (isset($before['properties']) || isset($after['properties'])) {
      $beforeProps = is_array($before['properties'] ?? NULL) ? $before['properties'] : NULL;
      $afterProps = is_array($after['properties'] ?? NULL) ? $after['properties'] : NULL;
      $nestedLevel = $this->diffSchema(
        $path,
        $beforeProps === NULL ? NULL : ['properties' => $beforeProps],
        $afterProps === NULL ? NULL : ['properties' => $afterProps],
        $changes,
      );
      $level = $this->mergeLevel($level, $nestedLevel);
    }

    if (isset($before['items']) || isset($after['items'])) {
      $beforeItems = is_array($before['items'] ?? NULL) ? $before['items'] : [];
      $afterItems = is_array($after['items'] ?? NULL) ? $after['items'] : [];
      if ($beforeItems !== $afterItems) {
        $itemsLevel = $this->diffProperty("$path.items", $beforeItems, $afterItems, $changes);
        $level = $this->mergeLevel($level, $itemsLevel);
      }
    }

    return $level;
  }

  /**
   * Diffs an enum constraint.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffEnum(string $path, array $before, array $after, array &$changes): string {
    $beforeEnum = $before['enum'] ?? NULL;
    $afterEnum = $after['enum'] ?? NULL;
    if ($beforeEnum === $afterEnum) {
      return 'none';
    }
    if ($beforeEnum === NULL && $afterEnum !== NULL) {
      $changes[] = ['path' => "$path.enum", 'kind' => 'enum_introduced', 'before' => NULL, 'after' => $afterEnum];
      return 'major';
    }
    if ($beforeEnum !== NULL && $afterEnum === NULL) {
      $changes[] = ['path' => "$path.enum", 'kind' => 'enum_removed', 'before' => $beforeEnum, 'after' => NULL];
      return 'minor';
    }
    $removed = array_diff($beforeEnum, $afterEnum);
    $added = array_diff($afterEnum, $beforeEnum);
    if ($removed !== []) {
      $changes[] = ['path' => "$path.enum", 'kind' => 'enum_value_removed', 'before' => $removed, 'after' => NULL];
      return 'major';
    }
    if ($added !== []) {
      $changes[] = ['path' => "$path.enum", 'kind' => 'enum_value_added', 'before' => NULL, 'after' => $added];
      return 'minor';
    }
    return 'none';
  }

  /**
   * Diffs the format constraint.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffFormat(string $path, array $before, array $after, array &$changes): string {
    $beforeFormat = $before['format'] ?? NULL;
    $afterFormat = $after['format'] ?? NULL;
    if ($beforeFormat === $afterFormat) {
      return 'none';
    }
    if ($beforeFormat === NULL) {
      $changes[] = ['path' => "$path.format", 'kind' => 'format_added', 'before' => NULL, 'after' => $afterFormat];
      return 'minor';
    }
    $changes[] = [
      'path' => "$path.format",
      'kind' => 'format_changed',
      'before' => $beforeFormat,
      'after' => $afterFormat,
    ];
    return 'major';
  }

  /**
   * Diffs the default value.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffDefault(string $path, array $before, array $after, array &$changes): string {
    $beforeHas = array_key_exists('default', $before);
    $afterHas = array_key_exists('default', $after);
    if (!$beforeHas && !$afterHas) {
      return 'none';
    }
    if (!$beforeHas && $afterHas) {
      $changes[] = [
        'path' => "$path.default",
        'kind' => 'default_added',
        'before' => NULL,
        'after' => $after['default'],
      ];
      return 'minor';
    }
    if (!$afterHas) {
      $changes[] = [
        'path' => "$path.default",
        'kind' => 'default_removed',
        'before' => $before['default'],
        'after' => NULL,
      ];
      return 'major';
    }
    if ($before['default'] !== $after['default']) {
      $changes[] = [
        'path' => "$path.default",
        'kind' => 'default_changed',
        'before' => $before['default'],
        'after' => $after['default'],
      ];
      return 'major';
    }
    return 'none';
  }

  /**
   * Generic numeric/length bound differ.
   *
   * @param string $path
   *   The path being diffed.
   * @param string $key
   *   The bound key (minimum, maximum, minLength, maxLength).
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   * @param bool $tighteningWhenRaised
   *   TRUE when raising the value tightens the bound (e.g. minimum).
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffNumericBound(
    string $path,
    string $key,
    array $before,
    array $after,
    array &$changes,
    bool $tighteningWhenRaised,
  ): string {
    $beforeHas = array_key_exists($key, $before);
    $afterHas = array_key_exists($key, $after);
    if (!$beforeHas && !$afterHas) {
      return 'none';
    }
    if (!$beforeHas && $afterHas) {
      // Bound introduced -> tightening -> major.
      $changes[] = ['path' => "$path.$key", 'kind' => "{$key}_added", 'before' => NULL, 'after' => $after[$key]];
      return 'major';
    }
    if (!$afterHas) {
      // Bound removed -> loosening -> minor.
      $changes[] = ['path' => "$path.$key", 'kind' => "{$key}_removed", 'before' => $before[$key], 'after' => NULL];
      return 'minor';
    }
    if ($before[$key] === $after[$key]) {
      return 'none';
    }
    $raised = $after[$key] > $before[$key];
    $tightened = $tighteningWhenRaised ? $raised : !$raised;
    $kind = $tightened ? "{$key}_tightened" : "{$key}_loosened";
    $changes[] = ['path' => "$path.$key", 'kind' => $kind, 'before' => $before[$key], 'after' => $after[$key]];
    return $tightened ? 'major' : 'minor';
  }

  /**
   * Diffs the pattern constraint.
   *
   * @param string $path
   *   The path being diffed.
   * @param array<string, mixed> $before
   *   The previous property schema.
   * @param array<string, mixed> $after
   *   The new property schema.
   * @param list<array{path: string, kind: string, before: mixed, after: mixed}> $changes
   *   Accumulated change records, by reference.
   *
   * @return 'major'|'minor'|'none'
   *   The merged change level.
   */
  private function diffPattern(string $path, array $before, array $after, array &$changes): string {
    $beforeHas = array_key_exists('pattern', $before);
    $afterHas = array_key_exists('pattern', $after);
    if (!$beforeHas && !$afterHas) {
      return 'none';
    }
    if (!$beforeHas && $afterHas) {
      $changes[] = [
        'path' => "$path.pattern",
        'kind' => 'pattern_added',
        'before' => NULL,
        'after' => $after['pattern'],
      ];
      return 'major';
    }
    if (!$afterHas) {
      $changes[] = [
        'path' => "$path.pattern",
        'kind' => 'pattern_removed',
        'before' => $before['pattern'],
        'after' => NULL,
      ];
      return 'minor';
    }
    if ($before['pattern'] === $after['pattern']) {
      return 'none';
    }
    $changes[] = [
      'path' => "$path.pattern",
      'kind' => 'pattern_changed',
      'before' => $before['pattern'],
      'after' => $after['pattern'],
    ];
    return 'major';
  }

  /**
   * Normalises a properties map by sorting on key.
   *
   * Property order doesn't affect contract.
   *
   * @param array<string, mixed> $properties
   *   The properties map.
   *
   * @return array<string, mixed>
   *   The sorted properties map.
   */
  private function normaliseProperties(array $properties): array {
    ksort($properties);
    return $properties;
  }

  /**
   * Merges two levels — major beats minor beats none.
   *
   * @param 'major'|'minor'|'none' $current
   *   The currently accumulated level.
   * @param 'major'|'minor'|'none' $candidate
   *   The new candidate level to merge in.
   *
   * @return 'major'|'minor'|'none'
   *   The merged level.
   */
  private function mergeLevel(string $current, string $candidate): string {
    $rank = ['none' => 0, 'minor' => 1, 'major' => 2];
    return $rank[$candidate] > $rank[$current] ? $candidate : $current;
  }

}
