<?php

namespace App\Services\Meta;

use App\Services\Platforms\Support\CallLogger;

/**
 * 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 pixels(string $adAccountId): array
    {
        return $this->graph->pixels($adAccountId);
    }

    /** @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);
    }

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

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

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

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

    /** @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',
        );
    }

    /**
     * Video creatives go over Graph even when the MCP is on.
     *
     * The MCP's arguments for a video creative have never been exercised against
     * a real account, and a creative is the one object where getting the shape
     * wrong produces an ad that exists and looks wrong rather than an error.
     * Graph's video_data payload is known to work. This is the same reasoning
     * that already sends uploads and deletes to Graph.
     */
    public function createCreative(CreativeSpec $spec): string
    {
        $transport = $spec->isVideo() ? null : $this->mcp;

        return ObjectId::require(
            $transport?->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);
    }

    /** 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;
    }
}
