<?php

declare(strict_types=1);

namespace Plugin\tnt_mcp\src\Mcp\Batch;

/**
 * `{{0.workflow.id}}` — how a batch entry uses what an earlier entry produced.
 *
 * ## Why this exists at all
 *
 * Without it a batch can only do INDEPENDENT work, and the interesting work is not independent:
 * create a workflow, then attach a schedule to the id it just got. That id does not exist when the
 * batch is written, so the model would have to split the plan into two requests and re-read the
 * first result — which is precisely the round trip a batch is supposed to remove. One reference
 * syntax turns "many calls at once" into "a plan".
 *
 * ## The rules, all of them
 *
 * - `{{N.path.to.field}}` — N is the ZERO-BASED index of an earlier entry, the rest is a path into
 *   that entry's result. `{{0.workflow.id}}` reads `results[0]['workflow']['id']`.
 * - A string that is ONLY a reference is replaced by the raw value, types intact: an integer id
 *   stays an integer, so `set_schedule.workflowId` still satisfies its schema.
 * - A reference INSIDE a longer string is interpolated as text ("bound to workflow {{0.workflow.id}}"),
 *   which is why only scalars may be interpolated — an array would stringify to "Array".
 * - Backwards only. A forward or self reference is an error, not an empty string.
 * - Unresolvable is ALWAYS an error naming the path. Substituting an empty value would write a
 *   workflow pointing at nothing and report success, which is the one outcome this tool surface
 *   exists to prevent.
 * - References are looked for in strings only, at any depth of the argument object.
 */
final class BatchReferences
{
    private const REFERENCE = '\{\{\s*(\d+)((?:\.[A-Za-z0-9_]+)+)\s*\}\}';
    private const PATTERN   = '/' . self::REFERENCE . '/';
    private const WHOLE     = '/^\s*' . self::REFERENCE . '\s*$/';

    /**
     * @param array<string,mixed>      $arguments the entry's arguments, as written by the model
     * @param array<int,array<string,mixed>> $done  results of the entries that already ran, by index
     * @param int                      $index     the entry being prepared, for "backwards only"
     *
     * @return array<string,mixed>
     */
    public static function resolve(array $arguments, array $done, int $index): array
    {
        /** @var array<string,mixed> $out */
        $out = self::walk($arguments, $done, $index);

        return $out;
    }

    /**
     * Does this argument object depend on an earlier entry at all?
     *
     * `dryRun` asks, because an entry that does can only be judged for real: the thing it points at
     * is created by an entry that a dry run did not run. Same pattern, same answer as
     * {@see resolve()} — asked instead of attempted, so it can be reported rather than thrown.
     *
     * @param array<string,mixed> $arguments
     */
    public static function areUsedIn(array $arguments): bool
    {
        foreach ($arguments as $value) {
            if (\is_array($value) && self::areUsedIn($value)) {
                return true;
            }
            if (\is_string($value) && \preg_match(self::PATTERN, $value) === 1) {
                return true;
            }
        }

        return false;
    }

    /**
     * WHICH references an argument object uses, in the order they are met, each written the way the
     * model wrote it.
     *
     * `areUsedIn()` answers "can this be judged"; a dry run also has to say what it could not judge,
     * or `deferred` is a verdict with no subject: the model is told an entry was not fully checked
     * without being told which value is the reason.
     *
     * @param array<string,mixed> $arguments
     *
     * @return list<string>
     */
    public static function usedIn(array $arguments): array
    {
        $found = [];
        self::collect($arguments, $found);

        return \array_values(\array_unique($found));
    }

    /**
     * @param list<string> $found
     */
    private static function collect(mixed $value, array &$found): void
    {
        if (\is_array($value)) {
            foreach ($value as $item) {
                self::collect($item, $found);
            }

            return;
        }
        if (!\is_string($value) || \preg_match_all(self::PATTERN, $value, $matches) < 1) {
            return;
        }
        foreach ($matches[0] as $match) {
            $found[] = $match;
        }
    }

    /**
     * @param array<int,array<string,mixed>> $done
     */
    private static function walk(mixed $value, array $done, int $index): mixed
    {
        if (\is_array($value)) {
            $out = [];
            foreach ($value as $key => $item) {
                $out[$key] = self::walk($item, $done, $index);
            }

            return $out;
        }
        if (!\is_string($value)) {
            return $value;
        }

        return self::substitute($value, $done, $index);
    }

    /**
     * @param array<int,array<string,mixed>> $done
     */
    private static function substitute(string $value, array $done, int $index): mixed
    {
        if (\preg_match(self::WHOLE, $value, $whole) === 1) {
            // The whole string is one reference — hand back the value itself, not its text.
            return self::lookup((int)$whole[1], $whole[2], $done, $index);
        }

        return \preg_replace_callback(
            self::PATTERN,
            static function (array $m) use ($done, $index): string {
                $found = self::lookup((int)$m[1], $m[2], $done, $index);
                if (\is_array($found) || \is_object($found)) {
                    throw new \InvalidArgumentException(\sprintf(
                        '%s is an object — a reference inside a longer string must point at a single value',
                        $m[0]
                    ));
                }
                if (\is_bool($found)) {
                    return $found ? 'true' : 'false';
                }

                return (string)$found;
            },
            $value
        ) ?? $value;
    }

    /**
     * @param array<int,array<string,mixed>> $done
     */
    private static function lookup(int $from, string $path, array $done, int $index): mixed
    {
        if ($from >= $index) {
            throw new \InvalidArgumentException(\sprintf(
                '{{%d%s}} points at call %d, which has not run yet — a reference must name an EARLIER call',
                $from,
                $path,
                $from
            ));
        }
        if (!\array_key_exists($from, $done)) {
            throw new \InvalidArgumentException(\sprintf(
                '{{%d%s}} points at call %d, which did not produce a result',
                $from,
                $path,
                $from
            ));
        }

        $current = $done[$from];
        foreach (\explode('.', \trim($path, '.')) as $segment) {
            if (!\is_array($current) || !\array_key_exists($segment, $current)) {
                throw new \InvalidArgumentException(\sprintf(
                    '{{%d%s}} does not exist in the result of call %d — it has no "%s"',
                    $from,
                    $path,
                    $from,
                    $segment
                ));
            }
            $current = $current[$segment];
        }

        return $current;
    }
}
