<?php

namespace App\Services\Meta;

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

/**
 * The Meta Graph API, called with the system user token.
 *
 * Used for everything the Ads MCP cannot do: reading accounts, pages and
 * pixels, uploading local files, and deleting. Also used for creation when the
 * MCP is switched off.
 */
class GraphApi
{
    public function __construct(
        private readonly string $token,
        private readonly string $version,
        private readonly CallLogger $log,
    ) {}

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

    /** @return list<array<string,mixed>> */
    public function adAccounts(int $limit = 50): array
    {
        return $this->get('me/adaccounts', [
            'fields' => 'id,name,account_status,currency,timezone_name',
            'limit' => $limit,
        ])['data'] ?? [];
    }

    /** @throws MetaException when the account is unreachable */
    public function adAccount(string $adAccountId): array
    {
        return $this->get($this->prefixed($adAccountId), [
            'fields' => 'id,name,account_status,disable_reason,currency,timezone_name,business',
        ]);
    }

    /** @return list<array<string,mixed>> */
    public function pages(int $limit = 50): array
    {
        return $this->get('me/accounts', ['fields' => 'id,name,category', 'limit' => $limit])['data'] ?? [];
    }

    /**
     * Instagram accounts the ads could run as.
     *
     * Two sources, because they answer different questions. The one connected
     * to the Page is the account an Instagram placement will use by default,
     * and is what the buyer almost always means. The ad account's own list is
     * everything else it has permission to run as, which is how a brand with
     * several handles picks a different one.
     *
     * Both edges return an empty list rather than failing when there is no
     * Instagram presence at all, which is a real and ordinary case.
     *
     * @return list<array{id: string, username: string|null, source: string}>
     */
    public function instagramAccounts(string $adAccountId, ?string $pageId = null): array
    {
        $accounts = [];

        if ($pageId) {
            $connected = $this->get($pageId, ['fields' => 'connected_instagram_account{id,username}']);

            if ($id = data_get($connected, 'connected_instagram_account.id')) {
                $accounts[$id] = [
                    'id' => (string) $id,
                    'username' => data_get($connected, 'connected_instagram_account.username'),
                    'source' => 'connected to the Page',
                ];
            }
        }

        foreach ($this->get($this->prefixed($adAccountId).'/instagram_accounts', [
            'fields' => 'id,username',
            'limit' => 50,
        ])['data'] ?? [] as $account) {
            $id = (string) ($account['id'] ?? '');

            if ($id === '' || isset($accounts[$id])) {
                continue;
            }

            $accounts[$id] = [
                'id' => $id,
                'username' => $account['username'] ?? null,
                'source' => 'on the ad account',
            ];
        }

        return array_values($accounts);
    }

    /**
     * Campaigns already on an account, whoever built them.
     *
     * Read over Graph rather than the MCP on purpose: the MCP returns values
     * shaped for people, "$25.00 USD" and "Cost per result goal", which cannot
     * be written back. Anything we intend to edit has to be read as the API
     * states it.
     *
     * @return list<array<string,mixed>>
     */
    public function campaigns(string $adAccountId, int $limit = 100): array
    {
        return $this->get($this->prefixed($adAccountId).'/campaigns', [
            'fields' => 'id,name,status,effective_status,objective,buying_type,created_time,updated_time',
            'limit' => $limit,
        ])['data'] ?? [];
    }

    /**
     * One campaign, as the API states it.
     *
     * The budget fields are here as well as on the ad set because a campaign
     * using Advantage campaign budget holds the money itself, and reading only
     * the ad set makes such a campaign look like it has no budget at all.
     */
    public function campaign(string $campaignId): array
    {
        return $this->get($campaignId, [
            'fields' => 'id,name,status,effective_status,objective,buying_type,account_id,'
                .'special_ad_categories,'
                .'daily_budget,lifetime_budget,bid_strategy,start_time,stop_time,created_time,updated_time',
        ]);
    }

    /**
     * The ad sets under a campaign.
     *
     * Budget, bidding, schedule and targeting live here rather than on the
     * campaign, so this is where everything we can edit actually is.
     *
     * @return list<array<string,mixed>>
     */
    public function adSets(string $campaignId, int $limit = 50): array
    {
        return $this->get($campaignId.'/adsets', [
            'fields' => 'id,name,status,daily_budget,lifetime_budget,bid_strategy,bid_amount,'
                .'billing_event,optimization_goal,start_time,end_time,targeting,promoted_object',
            'limit' => $limit,
        ])['data'] ?? [];
    }

    /**
     * Ad sets with delivery and learning signals for performance ingest.
     *
     * @return list<array<string, mixed>>
     */
    public function performanceAdSets(string $campaignId, int $limit = 50): array
    {
        return $this->get($campaignId.'/adsets', [
            'fields' => 'id,name,status,effective_status,daily_budget,lifetime_budget,bid_strategy,'
                .'targeting,learning_stage_info,issues_info',
            'limit' => $limit,
        ])['data'] ?? [];
    }

    /**
     * Performance for a live campaign.
     *
     * Insights, not the campaign object: spend and clicks live here. time_increment
     * of 1 yields one row per calendar day in the range.
     *
     * @return list<array<string, mixed>>
     */
    public function insights(string $campaignId, string $since, string $until, int|string $increment = 1): array
    {
        $rows = [];
        $after = null;

        do {
            $page = $this->get($campaignId.'/insights', array_filter([
                'fields' => 'campaign_id,spend,impressions,clicks,cpc,ctr,reach',
                'time_increment' => $increment,
                'time_range' => json_encode(['since' => $since, 'until' => $until]),
                'level' => 'campaign',
                'limit' => 100,
                'after' => $after,
            ], fn (mixed $value): bool => $value !== null && $value !== ''));

            array_push($rows, ...($page['data'] ?? []));
            $after = data_get($page, 'paging.cursors.after');
        } while (is_string($after) && $after !== '' && count($rows) < 2000);

        return $rows;
    }

    /**
     * Hourly performance for a live campaign.
     *
     * Meta has no time_increment=hourly. Hour buckets come from the advertiser
     * timezone breakdown, one set of 24 hours per calendar day when combined
     * with time_increment=1. reach is omitted: hourly breakdowns reject it.
     *
     * @return list<array<string, mixed>>
     */
    public function insightsHourly(string $campaignId, string $since, string $until): array
    {
        $rows = [];
        $after = null;

        do {
            $page = $this->get($campaignId.'/insights', array_filter([
                'fields' => 'campaign_id,spend,impressions,clicks,cpc,ctr',
                'time_increment' => 1,
                'time_range' => json_encode(['since' => $since, 'until' => $until]),
                'breakdowns' => 'hourly_stats_aggregated_by_advertiser_time_zone',
                'level' => 'campaign',
                'limit' => 100,
                'after' => $after,
            ], fn (mixed $value): bool => $value !== null && $value !== ''));

            array_push($rows, ...($page['data'] ?? []));
            $after = data_get($page, 'paging.cursors.after');
        } while (is_string($after) && $after !== '' && count($rows) < 2000);

        return $rows;
    }

    /**
     * Pixels, called Datasets in Meta's current interface.
     *
     * An ad account's own edge returns only the pixels assigned to it, often a
     * single default. The one an advertiser actually means is frequently owned
     * by the parent business and shared, so both are returned and labelled.
     *
     * @return list<array<string,mixed>>
     */
    public function pixels(string $adAccountId, int $limit = 50): array
    {
        $onAccount = collect($this->get($this->prefixed($adAccountId).'/adspixels', [
            'fields' => 'id,name,last_fired_time',
            'limit' => $limit,
        ])['data'] ?? [])->map(fn (array $pixel): array => [...$pixel, 'owned_by' => 'ad account']);

        $businessId = data_get($this->adAccount($adAccountId), 'business.id');

        if (! $businessId) {
            return $onAccount->all();
        }

        try {
            $owned = $this->get($businessId.'/owned_pixels', ['fields' => 'id,name', 'limit' => $limit])['data'] ?? [];
        } catch (MetaException) {
            return $onAccount->all();   // the business read is a bonus, not a requirement
        }

        return $onAccount
            ->concat(collect($owned)
                ->reject(fn (array $pixel): bool => $onAccount->contains('id', $pixel['id']))
                ->map(fn (array $pixel): array => [...$pixel, 'owned_by' => 'business']))
            ->values()
            ->all();
    }

    // ---------------------------------------------------------------- upload

    /** Uploads an image and returns the hash an ad creative references. */
    public function uploadImage(string $adAccountId, string $absolutePath, string $filename): string
    {
        $response = $this->multipart(
            $this->prefixed($adAccountId).'/adimages',
            'file',
            $absolutePath,
            $filename,
            timeout: 120,
        );

        // Meta keys the result by filename, which it may have normalised.
        $image = collect($response['images'] ?? [])->first();

        return $image['hash'] ?? throw new MetaException('Image upload returned no hash: '.json_encode($response));
    }

    /** Videos need processing time before an ad can use them. */
    public function uploadVideo(string $adAccountId, string $absolutePath, string $filename): string
    {
        $response = $this->multipart(
            $this->prefixed($adAccountId).'/advideos',
            'source',
            $absolutePath,
            $filename,
            timeout: 300,
        );

        return $response['id'] ?? throw new MetaException('Video upload returned no id: '.json_encode($response));
    }

    /**
     * Whether an uploaded video is ready to be put in an ad.
     *
     * Meta accepts the upload immediately and then transcodes, and an ad
     * created against a video still processing is rejected. The status is its
     * own read because the video edge does not return it alongside anything
     * else useful.
     *
     * Returns Meta's own word: ready, processing, or error.
     */
    public function videoStatus(string $videoId): string
    {
        return (string) (data_get($this->get($videoId, ['fields' => 'status']), 'status.video_status') ?: 'processing');
    }

    /**
     * A still from the video, which Meta requires on a video creative.
     *
     * Meta generates several once transcoding finishes and marks one preferred.
     * Taking the preferred one matches what the interface would have chosen,
     * and falling back to the first is better than refusing over a thumbnail.
     */
    public function videoThumbnail(string $videoId): ?string
    {
        $thumbnails = $this->get($videoId.'/thumbnails', ['fields' => 'uri,is_preferred'])['data'] ?? [];

        $preferred = collect($thumbnails)->firstWhere('is_preferred', true)
            ?? collect($thumbnails)->first();

        return $preferred['uri'] ?? null;
    }

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

    public function createCampaign(CampaignSpec $spec, bool $dryRun = false): array
    {
        return $this->post($this->prefixed($spec->adAccountId).'/campaigns', array_filter([
            'name' => $spec->name,
            'objective' => $spec->objective,
            'status' => 'PAUSED',
            'buying_type' => 'AUCTION',
            // Was hardcoded empty, which declared every campaign as no special
            // category whether or not anyone had been asked.
            'special_ad_categories' => json_encode($spec->declaredCategories()),
            // Required whenever the budget sits on the ad set rather than the
            // campaign. Omitting it fails with subcode 4834011.
            'is_adset_budget_sharing_enabled' => $spec->budgetSharing(),
            ...$spec->budgetFields(),
            'execution_options' => $dryRun ? json_encode(['validate_only']) : null,
        ]), dryRun: $dryRun);
    }

    public function createAdSet(AdSetSpec $spec): array
    {
        return $this->post($this->prefixed($spec->adAccountId).'/adsets', array_filter([
            'name' => $spec->name,
            'campaign_id' => $spec->campaignId,
            ...$spec->budgetFields(),
            '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,
            'status' => 'PAUSED',
            ...$spec->schedule(),
        ], fn (mixed $value): bool => $value !== null));
    }

    public function createCreative(CreativeSpec $spec): array
    {
        // No degrees_of_freedom_spec: Meta deprecated the standard enhancements
        // opt-out (subcode 3858504) and now wants individual features named.
        return $this->post($this->prefixed($spec->adAccountId).'/adcreatives', [
            'name' => $spec->name,
            'object_story_spec' => json_encode($spec->storySpec()),
            // Beside the story spec, not inside it.
            ...$spec->instagramFields(),
        ]);
    }

    public function createAd(string $adAccountId, string $adSetId, string $creativeId, string $name): array
    {
        return $this->post($this->prefixed($adAccountId).'/ads', [
            'name' => $name,
            'adset_id' => $adSetId,
            'creative' => json_encode(['creative_id' => $creativeId]),
            'status' => 'PAUSED',
        ]);
    }

    /**
     * Change fields on an existing object.
     *
     * @param  array<string,mixed>  $fields
     */
    public function updateEntity(string $objectId, array $fields, ?string $adAccountId = null): array
    {
        return $this->log->record(
            new CallContext('graph', 'POST', $objectId, $adAccountId, $fields, mutating: true),
            fn (): array => $this->unwrap(
                Http::timeout(60)->asForm()->post($this->url($objectId), [...$fields, 'access_token' => $this->token]),
                $objectId,
            ),
        );
    }

    /**
     * Deletion deliberately skips the allow list. If we created something, we
     * must always be able to remove it, even after the configuration changes.
     */
    public function delete(string $objectId): bool
    {
        $response = $this->log
            ->record(
                new CallContext('graph', 'DELETE', $objectId, mutating: true, guarded: false),
                fn (): array => $this->unwrap(
                    Http::timeout(60)->delete($this->url($objectId), ['access_token' => $this->token]),
                    $objectId,
                ),
            );

        return (bool) ($response['success'] ?? false);
    }

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

    private function get(string $endpoint, array $query = []): array
    {
        return $this->log->record(
            new CallContext('graph', 'GET', $endpoint, $this->accountIn($endpoint), $query),
            fn (): array => $this->unwrap(
                Http::timeout(60)->get($this->url($endpoint), [...$query, 'access_token' => $this->token]),
                $endpoint,
            ),
        );
    }

    private function post(string $endpoint, array $form, bool $dryRun = false): array
    {
        return $this->log->record(
            new CallContext('graph', 'POST', $endpoint, $this->accountIn($endpoint), $form, mutating: ! $dryRun, dryRun: $dryRun),
            fn (): array => $this->unwrap(
                Http::timeout(120)->asForm()->post($this->url($endpoint), [...$form, 'access_token' => $this->token]),
                $endpoint,
            ),
        );
    }

    /**
     * The file is checked before anything reads it.
     *
     * Both filesize() below and file_get_contents() in the closure raise a PHP
     * warning on a missing file, which becomes an ErrorException carrying the
     * absolute server path. That reached the buyer verbatim in the chat as
     * "filesize(): stat failed for /var/www/php85/...", which tells them
     * nothing they can act on and discloses the filesystem layout.
     */
    private function multipart(string $endpoint, string $field, string $path, string $filename, int $timeout): array
    {
        if (! is_file($path)) {
            throw new MetaException("The file for {$filename} is missing, so it could not be uploaded.");
        }

        return $this->log->record(
            new CallContext('graph', 'POST', $endpoint, $this->accountIn($endpoint), ['file' => $filename, 'bytes' => filesize($path)], mutating: true),
            fn (): array => $this->unwrap(
                Http::timeout($timeout)
                    ->asMultipart()
                    ->attach($field, file_get_contents($path), $filename)
                    ->post($this->url($endpoint), ['access_token' => $this->token]),
                $endpoint,
            ),
        );
    }

    /**
     * Meta buries the useful sentence in error_user_msg and leaves `message` as
     * "Invalid parameter". Lead with the one a human can act on.
     */
    private function unwrap(Response $response, string $endpoint): array
    {
        $body = $response->json();

        if (! is_array($body)) {
            throw new MetaException("Non-JSON response from {$endpoint}");
        }

        if ($error = $body['error'] ?? null) {
            throw new MetaException(sprintf(
                '%s [%s %s%s] on %s',
                rtrim(trim($error['error_user_msg'] ?? $error['error_user_title'] ?? $error['message'] ?? 'unknown error'), '.'),
                $error['type'] ?? '?',
                $error['code'] ?? '?',
                isset($error['error_subcode']) ? '/'.$error['error_subcode'] : '',
                $endpoint,
            ));
        }

        return $body;
    }

    private function url(string $endpoint): string
    {
        return "https://graph.facebook.com/{$this->version}/{$endpoint}";
    }

    private function prefixed(string $adAccountId): string
    {
        return str_starts_with($adAccountId, 'act_') ? $adAccountId : 'act_'.$adAccountId;
    }

    private function accountIn(string $endpoint): ?string
    {
        return preg_match('/^(act_\d+)/', $endpoint, $matches) ? $matches[1] : null;
    }
}
