<?php

namespace App\Services\Meta;

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

/**
 * The one thing the rest of the application talks to.
 *
 * Chooses a transport per operation: the Ads MCP for creation when it is
 * enabled, the Graph API for uploads and deletion, which the MCP cannot do.
 * Callers never need to know which ran; the audit log records it either way.
 */
class Meta
{
    public function __construct(
        private readonly GraphApi $graph,
        private readonly ?AdsMcp $mcp,
        private readonly CallLogger $log,
    ) {}

    /** Attribute everything that follows to a draft, for the audit trail. */
    public function on(?int $campaignId, ?string $actor): static
    {
        $this->log->attributeTo($campaignId, $actor);

        return $this;
    }

    // ------------------------------------------------------------------ read

    public function adAccounts(): array
    {
        return $this->graph->adAccounts();
    }

    public function adAccount(string $adAccountId): array
    {
        return $this->graph->adAccount($adAccountId);
    }

    public function pages(): array
    {
        return $this->graph->pages();
    }

    public function createPixel(string $adAccountId, string $name): array
    {
        return $this->graph->createPixel($adAccountId, $name);
    }

    public function pixels(string $adAccountId): array
    {
        return $this->graph->pixels($adAccountId);
    }

    /** @return array<string,int> event name => times seen, most frequent first */
    public function pixelEvents(string $pixelId): array
    {
        return $this->graph->pixelEvents($pixelId);
    }

    /** @return list<array{id: string, username: string|null, source: string}> */
    public function instagramAccounts(string $adAccountId, ?string $pageId = null): array
    {
        return $this->graph->instagramAccounts($adAccountId, $pageId);
    }

    /**
     * Campaigns already on the account, ours or anyone else's.
     *
     * Graph, not the MCP. The MCP formats values for reading, so a budget comes
     * back as "$25.00 USD" and a bid strategy as "Cost per result goal", and
     * neither can be diffed against a new value or written back.
     *
     * @return list<array<string,mixed>>
     */
    public function campaigns(string $adAccountId): array
    {
        return $this->graph->campaigns($adAccountId);
    }

    /**
     * @param  ?string  $adAccountId  the account this campaign belongs to, when
     *                                the caller already knows it, so the read
     *                                is checked against the allow list
     */
    public function campaign(string $campaignId, ?string $adAccountId = null): array
    {
        return $this->graph->campaign($campaignId, $adAccountId);
    }

    /** Which account a campaign is on, without reading the campaign itself. */
    public function campaignAccountId(string $campaignId): ?string
    {
        return $this->graph->campaignAccountId($campaignId);
    }

    /** @return list<array<string,mixed>> */
    public function adSets(string $campaignId, ?string $adAccountId = null): array
    {
        return $this->graph->adSets($campaignId, 50, $adAccountId);
    }

    /** @return list<array<string,mixed>> */
    public function performanceAdSets(string $campaignId, ?string $adAccountId = null): array
    {
        return $this->graph->performanceAdSets($campaignId, 50, $adAccountId);
    }

    /** @return list<array<string, mixed>> */
    public function insights(string $campaignId, string $since, string $until, int|string $increment = 1, ?string $adAccountId = null): array
    {
        return $this->graph->insights($campaignId, $since, $until, $increment, $adAccountId);
    }

    /** @return list<array<string, mixed>> */
    public function insightsHourly(string $campaignId, string $since, string $until): array
    {
        return $this->graph->insightsHourly($campaignId, $since, $until);
    }

    public function uploadImage(string $adAccountId, string $path, string $filename): string
    {
        return $this->graph->uploadImage($adAccountId, $path, $filename);
    }

    public function uploadVideo(string $adAccountId, string $path, string $filename): string
    {
        return $this->graph->uploadVideo($adAccountId, $path, $filename);
    }

    /**
     * Block until Meta has finished transcoding a video, or give up saying so.
     *
     * Meta accepts an upload immediately and transcodes afterwards, and an ad
     * built against a video that is still processing is rejected. Publishing is
     * synchronous from the chat, so this waits rather than scheduling: a short
     * bounded wait, because most creatives are a few seconds of video and the
     * person is watching a spinner.
     *
     * Giving up is not a failure of the creative. It is worth saying plainly so
     * they try again in a minute rather than concluding the video is broken.
     *
     * @throws MetaException when it is still not ready, or Meta could not process it
     */
    public function awaitVideoReady(string $videoId): void
    {
        $attempts = max(1, (int) config('platforms.meta.video_ready_attempts', 10));
        $waitMs = (int) config('platforms.meta.video_ready_wait_ms', 3000);

        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
            $status = $this->graph->videoStatus($videoId);

            if ($status === 'ready') {
                return;
            }

            if ($status === 'error') {
                throw new MetaException("Meta could not process video {$videoId}, so it cannot be used in an ad.");
            }

            if ($attempt < $attempts && $waitMs > 0) {
                usleep($waitMs * 1000);
            }
        }

        throw new MetaException(
            "Video {$videoId} is still being processed by Meta. Nothing was published; try again in a minute."
        );
    }

    /** A still Meta generated from the video, which a video creative requires. */
    public function videoThumbnail(string $videoId): ?string
    {
        return $this->graph->videoThumbnail($videoId);
    }

    // ---------------------------------------------------------------- create

    /**
     * Ask Meta to check a campaign payload without creating anything.
     *
     * Only the Graph API offers this, so it runs there even when the MCP is
     * handling creation. Cheap, and it turns "half a campaign then a rollback"
     * into a clean refusal when Meta changes its requirements.
     */
    public function validateCampaign(CampaignSpec $spec): void
    {
        $this->graph->createCampaign($spec, dryRun: true);
    }

    public function createCampaign(CampaignSpec $spec): string
    {
        return ObjectId::require(
            $this->mcp?->createCampaign($spec) ?? $this->graph->createCampaign($spec),
            'campaign',
        );
    }

    public function createAdSet(AdSetSpec $spec): string
    {
        return ObjectId::require(
            $this->mcp?->createAdSet($spec) ?? $this->graph->createAdSet($spec),
            'ad set',
        );
    }

    /**
     * The creative is made by whichever transport will make the ad.
     *
     * Video used to go over Graph even with the MCP on, because the MCP's video
     * arguments had never been exercised against a real account and a creative
     * is the one object where the wrong shape produces an ad that exists and
     * looks wrong rather than an error. That caution turned out to cost more
     * than it saved: a Graph creative carries its own object_story_spec, and an
     * MCP draft ad that references one is refused at publish with
     * ObjectStorySpecRedundant, so the campaign could never go live at all.
     *
     * Measured on 1 Oct against act_1238155271384134 rather than reasoned about.
     * Three ads built the old way - Graph creative, MCP ad - each carried that
     * error and blocked the whole publish. The same video through
     * ads_create_creative returned a creative id and one harmless warning, and a
     * draft ad referencing it came back with active_errors empty.
     *
     * So the rule is now that the creative and the ad share a transport. Uploads
     * and deletes still go to Graph, which is a different argument: those tools
     * are gated or absent on the MCP, rather than untested.
     */
    public function createCreative(CreativeSpec $spec): string
    {
        return ObjectId::require(
            $this->mcp?->createCreative($spec) ?? $this->graph->createCreative($spec),
            'ad creative',
        );
    }

    public function createAd(string $adAccountId, string $adSetId, string $creativeId, string $name): string
    {
        return ObjectId::require(
            $this->mcp?->createAd($adAccountId, $adSetId, $creativeId, $name)
                ?? $this->graph->createAd($adAccountId, $adSetId, $creativeId, $name),
            'ad',
        );
    }

    /**
     * Change a live object.
     *
     * @param  array<string,mixed>  $fields  platform field names, not create arguments
     */
    public function updateEntity(string $adAccountId, string $entityId, string $entityType, array $fields): void
    {
        $this->mcp?->updateEntity($adAccountId, $entityId, $entityType, $fields)
            ?? $this->graph->updateEntity($entityId, $fields, $adAccountId);
    }

    /**
     * Meta's own thumbnail, handed back to Meta as an uploaded image.
     *
     * Absurd on the face of it, and still the only route. The MCP will not take
     * a video creative without image_hash or image_url, refuses the signed
     * scontent URL that videoThumbnail() returns, and /adimages answers "(#3)
     * Application does not have the capability" to a url= upload on this app.
     * That leaves fetching the bytes and posting them, which is what the image
     * path already does for an ordinary creative.
     *
     * Through PublicHttp because the address came from an API response rather
     * than from us, and everything else that fetches one goes through the same
     * guard.
     */
    public function uploadImageFromUrl(string $adAccountId, string $url, string $filename): string
    {
        $response = PublicHttp::send(Http::connectTimeout(5)->timeout(30), $url);

        if ($response->failed()) {
            throw new MetaException("The video thumbnail could not be fetched (HTTP {$response->status()}).");
        }

        $path = tempnam(sys_get_temp_dir(), 'arb-thumb-');

        try {
            file_put_contents($path, $response->body());

            return $this->graph->uploadImage($adAccountId, $path, $filename);
        } finally {
            @unlink($path);
        }
    }

    /**
     * Settle whatever creation left behind, so the campaign exists and is paused.
     *
     * Only the MCP needs this. Graph's creates send status PAUSED and produce
     * live objects, so there is nothing staged and nothing to publish; the MCP
     * produces an unpublished draft staged ACTIVE, which is neither.
     *
     * @param  list<array{id: string, type: string}>  $objects  campaign first, then ad set, then ads
     */
    public function settleAsPaused(string $adAccountId, array $objects): void
    {
        $this->mcp?->publishDraftPaused($adAccountId, $objects);
    }

    /** Graph only: the MCP has no delete tool for campaigns, ad sets or ads. */
    public function delete(string $objectId): bool
    {
        return $this->graph->delete($objectId);
    }

    public function usingMcp(): bool
    {
        return $this->mcp !== null;
    }
}
