<?php

namespace App\Services\Meta;

use App\Services\Platforms\Support\CallContext;
use App\Services\Platforms\Support\CallLogger;
use Illuminate\Support\Facades\Http;

/**
 * Meta's Ads MCP.
 *
 * Preferred for creation because Meta owns the validation: it rejects illegal
 * objective and optimisation pairings, applies its own defaults, returns the
 * valid optimisation goals alongside a new campaign, and creates everything
 * paused. Breaking changes to the request format become Meta's problem rather
 * than ours.
 *
 * It cannot replace the Graph API entirely, for two reasons verified against
 * the live endpoint rather than assumed:
 *
 *   Uploads   Every upload tool is still gated ("gradually rolled out") on our
 *             accounts, and the local-file variant is interactive by design: it
 *             opens Meta's own picker for a human. The only server-to-server
 *             path is upload_source=URL, which would mean publishing every
 *             creative to a public URL first. Graph takes the bytes directly.
 *
 *   Deletion  There is no delete tool for campaigns, ad sets or ads. Creatives,
 *             images and custom audiences can be deleted; the objects the
 *             rollback path unwinds cannot.
 *
 * Requires the ads_mcp_management scope. Replies arrive as server-sent events.
 */
class AdsMcp
{
    public function __construct(
        private readonly string $token,
        private readonly string $endpoint,
        private readonly CallLogger $log,
    ) {}

    /** @param  list<string>  $specialAdCategories */
    public function createCampaign(CampaignSpec $spec): array
    {
        return $this->call('ads_create_campaign', array_filter([
            'ad_account_id' => $this->bare($spec->adAccountId),
            'campaign_name' => $spec->name,
            'objective' => $spec->objective,
            'buying_type' => 'AUCTION',
            // Was hardcoded empty. NONE is our own answer and Meta expects it
            // as an empty list, so it is filtered rather than sent.
            'special_ad_categories' => json_encode($spec->declaredCategories()),
            ...$spec->budgetFields(),
        ], fn (mixed $value): bool => $value !== null),
            $spec->adAccountId,
            "Create a {$spec->objective} campaign named {$spec->name}",
        );
    }

    public function createAdSet(AdSetSpec $spec): array
    {
        return $this->call('ads_create_ad_set', array_filter([
            'ad_account_id' => $this->bare($spec->adAccountId),
            'campaign_id' => $spec->campaignId,
            'ad_set_name' => $spec->name,
            'billing_event' => 'IMPRESSIONS',
            'optimization_goal' => $spec->optimizationGoal,
            'targeting' => json_encode($spec->targetingSpec()),
            // Omitted rather than sent empty: Meta reads "[]" as a promoted
            // object it cannot make sense of, not as the absence of one.
            'promoted_object' => $spec->promotedObject() ? json_encode($spec->promotedObject()) : null,
            ...$spec->budgetFields(),
            ...$spec->schedule(),
        ], fn (mixed $value): bool => $value !== null),
            $spec->adAccountId,
            $this->adSetSummary($spec),
        );
    }

    /** What the audit log records the call as, in money rather than minor units. */
    private function adSetSummary(AdSetSpec $spec): string
    {
        if ($spec->budgetLevel === 'campaign') {
            return "Ad set {$spec->name}, budget held on the campaign";
        }

        return sprintf(
            'Ad set %s at %s %s',
            $spec->name,
            number_format($spec->budgetMinorUnits / 100, 2),
            $spec->budgetMode === 'lifetime' ? 'in total' : 'a day',
        );
    }

    public function createCreative(CreativeSpec $spec): array
    {
        return $this->call('ads_create_creative', $spec->mcpArguments(), $spec->adAccountId, "Ad creative {$spec->name}");
    }

    public function createAd(string $adAccountId, string $adSetId, string $creativeId, string $name): array
    {
        return $this->call('ads_create_ad', [
            'ad_account_id' => $this->bare($adAccountId),
            'ad_set_id' => $adSetId,
            'ad_name' => $name,
            'creative' => json_encode(['creative_id' => $creativeId]),
        ], $adAccountId, "Ad {$name}");
    }

    /**
     * Change fields on a live campaign, ad set or ad.
     *
     * Meta names update fields differently from create arguments: a campaign's
     * budget is daily_budget here and campaign_daily_budget on create. Callers
     * pass the update names.
     *
     * @param  array<string,mixed>  $fields
     */
    public function updateEntity(string $adAccountId, string $entityId, string $entityType, array $fields): array
    {
        return $this->call('ads_update_entity', [
            'ad_account_id' => $this->bare($adAccountId),
            'entity_id' => $entityId,
            'entity_type' => $entityType,
            'fields' => json_encode($fields),
        ], $adAccountId, 'Update '.$entityType.' '.$entityId);
    }

    /** @return list<array<string,mixed>> every account, with its MCP eligibility */
    public function adAccounts(): array
    {
        return $this->call('ads_get_ad_accounts', [], null)['ad_accounts'] ?? [];
    }

    // ------------------------------------------------------------- internals

    /**
     * @param  string|null  $advertiserRequest  the user's own words; Meta asks for
     *                                          these to be passed through, not paraphrased
     */
    private function call(string $tool, array $arguments, ?string $adAccountId, ?string $advertiserRequest = null): array
    {
        // Meta requires this on every call and uses it to correlate a session.
        $arguments['client_conversation_id'] = 'arb-'.($adAccountId ?? 'general');

        if ($advertiserRequest !== null) {
            $arguments['advertiser_request'] = $advertiserRequest;
        }

        return $this->log->record(
            new CallContext('mcp', 'CALL', $tool, $adAccountId, $arguments, mutating: self::writes($tool)),
            fn (): array => $this->invoke($tool, $arguments),
        );
    }

    /**
     * Whether a tool changes anything, decided by what is known to be a read.
     *
     * This was "the name contains create", which classed ads_update_entity as a
     * read. That is the call that moves a live campaign's budget, and because
     * the kill switch only applies to mutating calls, setting
     * PLATFORM_PAUSE_ALL_WRITES would not have stopped it.
     *
     * Inverted deliberately: anything not recognisably a read counts as a
     * write, so a tool nobody has seen yet is treated as dangerous rather than
     * harmless.
     */
    private static function writes(string $tool): bool
    {
        $reads = str_starts_with($tool, 'ads_get_')
            || str_contains($tool, '_get_')
            || str_contains($tool, '_list_')
            || in_array($tool, ['ads_library_search', 'ads_insights_anomaly_signal'], true);

        return ! $reads;
    }

    private function invoke(string $tool, array $arguments): array
    {
        $response = Http::timeout(120)
            ->withToken($this->token)
            ->withHeaders(['Accept' => 'application/json, text/event-stream'])
            ->post($this->endpoint, [
                'jsonrpc' => '2.0',
                'id' => 1,
                'method' => 'tools/call',
                'params' => ['name' => $tool, 'arguments' => $arguments],
            ]);

        $envelope = $this->decodeEventStream($response->body());

        if ($error = $envelope['error'] ?? null) {
            throw new MetaException(sprintf(
                '%s [MCP %s] on %s',
                $error['message'] ?? 'unknown MCP error',
                $error['code'] ?? '?',
                $tool,
            ));
        }

        $result = $envelope['result'] ?? [];
        $text = collect($result['content'] ?? [])->pluck('text')->implode('');
        $payload = json_decode($text, true) ?: ['text' => $text];

        // A tool can run successfully and still report that the operation
        // failed, so isError has to be checked separately from the JSON-RPC
        // error above.
        if (($result['isError'] ?? false) || isset($payload['error'])) {
            $reason = $payload['error']['message'] ?? $payload['error'] ?? $payload['message'] ?? mb_substr($text, 0, 300);

            throw new MetaException(sprintf(
                '%s [MCP] on %s',
                is_string($reason) ? $reason : json_encode($reason),
                $tool,
            ));
        }

        return $payload;
    }

    /** The payload arrives on a `data:` line rather than as a plain body. */
    private function decodeEventStream(string $body): array
    {
        foreach (explode("\n", $body) as $line) {
            if (str_starts_with($line = trim($line), 'data: ')) {
                return json_decode(substr($line, 6), true) ?: [];
            }
        }

        return json_decode($body, true) ?: [];
    }

    /** The MCP wants a bare numeric id; the Graph API wants the act_ prefix. */
    private function bare(string $adAccountId): string
    {
        return ltrim(str_replace('act_', '', $adAccountId), '_');
    }
}
