<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Action;

use Plugin\tnt_mcp\src\Mcp\Mode\EndpointClass;

/**
 * One invocable backend action, as the catalog declares it.
 *
 * Measurement, not preference, put this in a YAML file: roughly half of what an invoker needs —
 * the hidden `const` switches, which fields are co-required, that an update deletes and re-inserts
 * its child rows, that a save silently does nothing when one of four inputs is missing — exists
 * only inside a controller body and cannot be read out of the database or the routing table. This
 * class is that hand-written half, and it is inert: it holds declarations, it does not invoke, bind
 * or query anything.
 */
final class ActionSpec
{
    /**
     * @param array<string,string>  $title         language code => label, always including de + en
     * @param list<SideEffect>      $sideEffects
     * @param list<FieldSpec>       $fields
     * @param list<VerifyAssertion> $verify        empty only for Destructive entries
     * @param list<string>          $recipe
     * @param list<string>          $prerequisites
     * @param list<CoRequirement>   $coRequired
     * @param list<NextStep>        $nextSteps     the G3 edges: what usually comes after this action
     * @param list<string>          $pitfalls
     * @param list<EvidenceRef>     $evidence
     * @param ?string               $controller    FQCN of the backend controller, `adminIo` only
     * @param ?string               $method        the public method invoked on it, `adminIo` only
     * @param ?AdminIoParams        $params        the argument shape it wants, `adminIo` only
     */
    private function __construct(
        public readonly string $id,
        public readonly ActionKind $kind,
        public readonly string $route,
        public readonly ?string $controller,
        public readonly ?string $method,
        public readonly ?AdminIoParams $params,
        public readonly string $permission,
        public readonly array $title,
        public readonly array $sideEffects,
        public readonly bool $dryRunnable,
        public readonly EndpointClass $endpointClass,
        public readonly array $fields,
        public readonly array $verify,
        public readonly ?JournalSpec $journal,
        public readonly ?IdResolution $idResolution,
        public readonly array $recipe,
        public readonly array $prerequisites,
        public readonly array $coRequired,
        public readonly array $nextSteps,
        public readonly array $pitfalls,
        public readonly ?UndoSpec $undo,
        public readonly ?string $guide,
        public readonly ?string $sideEffectNote,
        public readonly array $evidence,
    ) {
    }

    /**
     * Assumes {@see ActionCatalogValidator} has already passed on this entry.
     *
     * @param array<mixed> $raw
     */
    public static function fromArray(array $raw): self
    {
        $title = [];
        foreach (\is_array($raw['title'] ?? null) ? $raw['title'] : [] as $language => $label) {
            if (\is_string($label)) {
                $title[(string)$language] = $label;
            }
        }
        $sideEffects = [];
        foreach (\is_array($raw['sideEffects'] ?? null) ? $raw['sideEffects'] : [] as $effect) {
            if (\is_string($effect)) {
                $sideEffects[] = SideEffect::from($effect);
            }
        }

        return new self(
            \is_string($raw['id'] ?? null) ? $raw['id'] : '',
            ActionKind::from(\is_string($raw['kind'] ?? null) ? $raw['kind'] : ''),
            \is_string($raw['route'] ?? null) ? $raw['route'] : '',
            \is_string($raw['controller'] ?? null) ? $raw['controller'] : null,
            \is_string($raw['method'] ?? null) ? $raw['method'] : null,
            \is_string($raw['params'] ?? null) ? AdminIoParams::tryFrom($raw['params']) : null,
            \is_string($raw['permission'] ?? null) ? $raw['permission'] : '',
            $title,
            $sideEffects,
            (bool)($raw['dryRunnable'] ?? false),
            self::endpointClass(\is_string($raw['endpointClass'] ?? null) ? $raw['endpointClass'] : ''),
            self::objects($raw['fields'] ?? null, FieldSpec::fromArray(...)),
            self::objects($raw['verify'] ?? null, VerifyAssertion::fromArray(...)),
            \is_array($raw['journal'] ?? null) ? JournalSpec::fromArray($raw['journal']) : null,
            \is_array($raw['idResolution'] ?? null) ? IdResolution::fromArray($raw['idResolution']) : null,
            self::strings($raw['recipe'] ?? null),
            self::strings($raw['prerequisites'] ?? null),
            self::objects($raw['coRequired'] ?? null, CoRequirement::fromArray(...)),
            self::objects($raw['nextSteps'] ?? null, NextStep::fromArray(...)),
            self::strings($raw['pitfalls'] ?? null),
            \is_array($raw['undo'] ?? null) ? UndoSpec::fromArray($raw['undo']) : null,
            \is_string($raw['guide'] ?? null) ? $raw['guide'] : null,
            \is_string($raw['sideEffectNote'] ?? null) ? $raw['sideEffectNote'] : null,
            self::objects($raw['evidence'] ?? null, EvidenceRef::fromArray(...)),
        );
    }

    /**
     * True when this entry is invoked by calling a backend IO method directly rather than by
     * submitting a form. The three `adminIo` keys are non-null exactly then — the validator
     * requires all three on an `adminIo` entry and forbids all three on a `formPost` one.
     */
    public function isAdminIo(): bool
    {
        return $this->kind === ActionKind::AdminIo;
    }

    public function titleFor(string $language): ?string
    {
        return $this->title[$language] ?? null;
    }

    public function field(string $name): ?FieldSpec
    {
        foreach ($this->fields as $field) {
            if ($field->name === $name) {
                return $field;
            }
        }

        return null;
    }

    /** @return list<string> declared field names, in declaration order */
    public function fieldNames(): array
    {
        return \array_map(static fn (FieldSpec $field): string => $field->name, $this->fields);
    }

    public function hasSideEffect(SideEffect $effect): bool
    {
        return \in_array($effect, $this->sideEffects, true);
    }

    /**
     * {@see EndpointClass} is a pure enum, so the YAML string is mapped here. Fail-safe on an
     * unknown value the same way {@see \Plugin\tnt_mcp\src\Mcp\Mode\ModeGuard} is: Destructive.
     */
    private static function endpointClass(string $value): EndpointClass
    {
        return $value === 'Reversible' ? EndpointClass::Reversible : EndpointClass::Destructive;
    }

    /**
     * @template T of object
     * @param callable(array<mixed>): T $factory
     * @return list<T>
     */
    private static function objects(mixed $value, callable $factory): array
    {
        $out = [];
        foreach (\is_array($value) ? $value : [] as $entry) {
            if (\is_array($entry)) {
                $out[] = $factory($entry);
            }
        }

        return $out;
    }

    /** @return list<string> */
    private static function strings(mixed $value): array
    {
        $out = [];
        foreach (\is_array($value) ? $value : [] as $item) {
            if (\is_string($item)) {
                $out[] = $item;
            }
        }

        return $out;
    }
}
