<?php

namespace App\Agent;

use Laravel\Ai\Tools\Request as ToolRequest;
use Throwable;

/**
 * Applies an answer the user clicked, without asking the model to interpret it.
 *
 * Every option already carries the value the platform accepts. It was never
 * sent: clicking put a sentence in the composer and the model had to turn that
 * back into an argument. Where the sentence contained the value that worked,
 * and where it did not the model sometimes called the tool with nothing, which
 * re-offered the same question. Clicking "Highest volume" or "Standard tagging"
 * did nothing at all until you clicked it twice. Confirmed in the browser on
 * 16 Sep on both.
 *
 * So the value is now posted and applied here, before the turn runs. The model
 * still sees the message and still replies; it simply no longer decides whether
 * the answer was recorded.
 *
 * This is not a second way into the agent. It runs the same tool objects from
 * the same registry that the model would have called, so the guardrails, the
 * audit log and the workspace are identical. The controller already calls tools
 * directly this way when it has to put options on screen itself.
 */
class Answers
{
    /**
     * Which tool owns each question, and the argument it expects.
     *
     * Keyed by the field name the offering tool passes to Choices::offer(), so
     * the two sides are named the same thing on purpose.
     *
     * The namespace defaults to `campaign`, which is where all of these lived
     * while Meta was the only platform. Google's tools are registered under
     * their own, so the entry has to say which registry name to look for.
     *
     * @var array<string, array{tool: string, argument: string, shape?: string, namespace?: string}>
     */
    private const FIELDS = [
        // Several at once: one brief, a campaign on each platform chosen.
        'platforms' => ['tool' => 'start_campaign', 'argument' => 'platforms', 'shape' => 'list'],
        'platform' => ['tool' => 'start_campaign', 'argument' => 'platform'],
        'ad_account_id' => ['tool' => 'verify_ad_account', 'argument' => 'ad_account_id'],
        'campaign_type' => ['tool' => 'choose_campaign_type', 'argument' => 'campaign_type'],
        'objective' => ['tool' => 'choose_objective', 'argument' => 'objective'],
        'special_ad_categories' => ['tool' => 'declare_ad_category', 'argument' => 'categories', 'shape' => 'list'],
        'page_id' => ['tool' => 'choose_page', 'argument' => 'page_id'],
        'pixel_id' => ['tool' => 'choose_pixel', 'argument' => 'pixel_id'],
        'budget_amount' => ['tool' => 'set_campaign_budget', 'argument' => 'amount', 'shape' => 'number'],
        'budget_strategy' => ['tool' => 'set_campaign_budget', 'argument' => 'strategy', 'shape' => 'strategy'],
        // How one total is divided when a brief runs on several platforms.
        'budget_split' => ['tool' => 'split_budget', 'argument' => 'split'],
        'bid_strategy' => ['tool' => 'set_bidding', 'argument' => 'strategy'],
        // The second half of a capped strategy. Applied without naming the
        // strategy: the tool reads the one already on the campaign.
        'bid_amount' => ['tool' => 'set_bidding', 'argument' => 'target_cost'],
        'ends_at' => ['tool' => 'set_schedule', 'argument' => 'ends_at'],
        'countries' => ['tool' => 'set_targeting', 'argument' => 'countries', 'shape' => 'list'],
        'placements' => ['tool' => 'set_targeting', 'argument' => 'placements', 'shape' => 'list'],
        'devices' => ['tool' => 'set_targeting', 'argument' => 'devices', 'shape' => 'commas'],
        'conversion_event' => ['tool' => 'choose_conversion_event', 'argument' => 'conversion_event'],
        'instagram_account_id' => ['tool' => 'choose_instagram_account', 'argument' => 'instagram_account_id'],
        'utm' => ['tool' => 'set_tracking', 'argument' => 'utm'],
        'creative_ids' => ['tool' => 'import_creatives', 'argument' => 'creative_ids', 'shape' => 'ints'],
        'tiktok_identity' => ['tool' => 'choose_tik_tok_identity', 'argument' => 'identity'],

        // Google's equivalent of a pixel. Applied by the shared tool, which
        // stores it on the campaign like Meta's, so one column answers "what
        // counts a conversion here" whatever the platform.
        //
        // There is no customer_id entry. Choosing a Google account was a
        // question with one answer once Google became a single common account,
        // so the tool that owned it is no longer registered.
        'conversion_action_resource_name' => ['tool' => 'choose_pixel', 'argument' => 'pixel_id'],
    ];

    public function __construct(private readonly ToolRegistry $registry) {}

    /** Whether this product knows how to apply an answer to this question. */
    public function handles(string $field): bool
    {
        return isset(self::FIELDS[$field]);
    }

    /** @return list<string> every question that can be answered without the model */
    public function fields(): array
    {
        return array_keys(self::FIELDS);
    }

    /**
     * Apply one answer, and say whether it landed.
     *
     * Never throws. An unknown field, a missing tool or a tool that refuses all
     * leave the turn exactly as it was before this existed: the message still
     * reaches the model, which can still do the work. Degrading to the old
     * behaviour is the point, because the old behaviour mostly worked.
     *
     * @param  string|list<string>  $value
     */
    public function apply(string $field, string|array $value): bool
    {
        if (! $this->handles($field)) {
            return false;
        }

        $definition = self::FIELDS[$field];

        return $this->call($this->qualified($definition), $this->arguments($definition, $value));
    }

    /**
     * The registry name of the tool that owns a question.
     *
     * @param  array{tool: string, argument: string, shape?: string, namespace?: string}  $definition
     */
    private function qualified(array $definition): string
    {
        return ($definition['namespace'] ?? 'campaign').NamespacedTool::SEPARATOR.$definition['tool'];
    }

    /**
     * Apply everything that was clicked, one call per tool.
     *
     * Answers are merged by the tool that owns them rather than applied one at
     * a time, because a tool asked for half of what it needs puts the other
     * half back on screen. Clicking "daily" and "10" together sent
     * set_campaign_budget(level, mode) with no amount, which re-offered the
     * amounts, and then set_campaign_budget(amount) which set it: the question
     * was answered and re-asked in the same turn. Targeting did the same thing
     * with countries, placements and devices, and the screen showed the
     * placements again underneath the next step. Seen on arb-dev on 17 Sep.
     *
     * Merged, the tool is handed everything at once and has nothing left to ask.
     *
     * @param  list<array{field?: string, value?: mixed}>  $answers
     * @return list<array{field: string, value: mixed, applied: bool}>
     */
    public function applyAll(array $answers): array
    {
        $calls = [];
        $fieldsByTool = [];
        $chosen = [];

        foreach ($answers as $answer) {
            $field = (string) ($answer['field'] ?? '');
            $value = $answer['value'] ?? '';

            // Numbers are accepted as well as strings: JSON carries an unquoted
            // budget as an int, and silently skipping it would drop the answer
            // with nothing to show for it. Booleans are not an answer to
            // anything offered here.
            $usable = is_string($value) || is_array($value) || is_int($value) || is_float($value);

            if (! $this->handles($field) || ! $usable) {
                continue;
            }

            $value = is_array($value) ? $value : (string) $value;

            $definition = self::FIELDS[$field];
            $tool = $this->qualified($definition);

            // Later answers win on a clash, which only happens when one screen
            // offers the same argument twice, and the newer one is the answer.
            $calls[$tool] = [
                ...$calls[$tool] ?? [],
                ...$this->arguments($definition, $value),
            ];

            $fieldsByTool[$tool][] = $field;
            $chosen[$field] = $value;
        }

        // What was taken and what was not, so the turn can say so.
        //
        // This returned void, and the controller sent the model the word
        // "Continue." when a click arrived with no typed message. So the model
        // was never told what the buyer had chosen: it answered the question
        // that was already settled, or moved on without a word about the choice,
        // and a click read as though nothing had happened. Koushik and Kapil both
        // clicked the same option twice for that reason.
        $applied = [];

        foreach ($calls as $tool => $arguments) {
            $took = $this->call($tool, $arguments);

            foreach ($fieldsByTool[$tool] ?? [] as $field) {
                $applied[] = [
                    'field' => $field,
                    'value' => $chosen[$field] ?? null,
                    'applied' => $took,
                ];
            }
        }

        return $applied;
    }

    /**
     * Run one tool from the registry, and say whether it took the answer.
     *
     * @param  string  $name  the full registry name, namespace included
     * @param  array<string, mixed>  $arguments
     */
    private function call(string $name, array $arguments): bool
    {
        $tool = collect($this->registry->resolve())
            ->first(fn ($candidate): bool => $candidate->name() === $name);

        if (! $tool) {
            return false;
        }

        try {
            return $this->landed($tool->handle(new ToolRequest($arguments)));
        } catch (Throwable $e) {
            report($e);

            return false;
        }
    }

    /**
     * Whether the tool actually took the answer.
     *
     * Running is not the same as succeeding. A tool that cannot reach the
     * platform, or is handed a value the guardrails refuse, returns its refusal
     * as an ordinary result rather than throwing, and treating that as applied
     * would leave the model believing the question was settled when it was not.
     *
     * Tools that answer in a sentence rather than JSON are taken at their word,
     * because they only reply that way when they succeeded.
     */
    private function landed(\Stringable|string $result): bool
    {
        $decoded = json_decode((string) $result, true);

        return ! is_array($decoded) || ($decoded['ok'] ?? true) !== false;
    }

    /**
     * The tool's arguments for this answer.
     *
     * An empty string survives on purpose: "no tracking" and "run the Instagram
     * ads under the Page" are both answers, and both tools tell them apart from
     * never having been asked by whether the key is present at all.
     *
     * @param  array{tool: string, argument: string, shape?: string}  $definition
     * @param  string|list<string>  $value
     * @return array<string, mixed>
     */
    private function arguments(array $definition, string|array $value): array
    {
        $shape = $definition['shape'] ?? 'string';

        // The budget's level and mode are one click, crossed into one option so
        // the whole of "how the budget works" is a single screen.
        if ($shape === 'strategy') {
            [$level, $mode] = array_pad(explode(':', (string) (is_array($value) ? reset($value) : $value), 2), 2, null);

            return array_filter(['level' => $level, 'mode' => $mode], fn (?string $part): bool => filled($part));
        }

        $argument = $definition['argument'];

        return [$argument => match ($shape) {
            'list' => array_values(array_filter((array) $value, fn ($item): bool => $item !== null)),
            'ints' => array_values(array_map(intval(...), array_filter((array) $value))),
            'commas' => array_values(array_filter(explode(',', (string) (is_array($value) ? implode(',', $value) : $value)))),
            'number' => (float) (is_array($value) ? reset($value) : $value),
            default => (string) (is_array($value) ? reset($value) : $value),
        }];
    }
}
