<?php

namespace App\Services\Meta;

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,
    ) {}

    public function createCampaign(string $adAccountId, string $name, string $objective): array
    {
        return $this->call('ads_create_campaign', [
            'ad_account_id' => $this->bare($adAccountId),
            'campaign_name' => $name,
            'objective' => $objective,
            'buying_type' => 'AUCTION',
            'special_ad_categories' => '[]',
        ], $adAccountId, "Create a {$objective} campaign named {$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()),
            'promoted_object' => json_encode($spec->promotedObject()),
            'daily_budget' => $spec->dailyBudgetCents,
            'bid_strategy' => $spec->bidStrategy,
            'bid_amount' => $spec->bidAmountCents,
            ...$spec->schedule(),
        ], fn (mixed $value): bool => $value !== null),
            $spec->adAccountId,
            "Ad set {$spec->name} at {$spec->dailyBudgetCents} cents 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: str_contains($tool, 'create')),
            fn (): array => $this->invoke($tool, $arguments),
        );
    }

    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), '_');
    }
}
