<?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 countryCandidates(string $countryName, ?string $adAccountId = null): array
    {
        return (array) ($this->get('search', [
            'type' => 'adgeolocation',
            'location_types' => json_encode(['country']),
            'q' => $countryName,
        ], $adAccountId)['data'] ?? []);
    }

    /** @return list<array<string, mixed>> */
    public function locationCandidates(string $kind, string $countryCode, string $term, ?string $adAccountId = null, int $page = 1): array
    {
        if (! in_array($kind, ['city', 'region'], true)
            || ! preg_match('/^[A-Z]{2}$/D', $countryCode)
            || ! preg_match('/^[\pL\pN .\x27,-]{2,80}$/uD', trim($term))
            || $page < 1 || $page > 4) {
            throw new \InvalidArgumentException('Provide a country code and a city or region name of 2 to 80 characters.');
        }

        $after = null;

        for ($current = 1; $current <= $page; $current++) {
            $response = $this->get('search', array_filter([
                'type' => 'adgeolocation',
                'location_types' => json_encode([$kind]),
                'country_code' => $countryCode,
                'q' => trim($term),
                'limit' => 25,
                'after' => $after,
            ], fn (mixed $value): bool => $value !== null), $adAccountId);

            if ($current === $page) {
                return (array) ($response['data'] ?? []);
            }

            $after = data_get($response, 'paging.cursors.after');

            if (! is_string($after) || $after === '') {
                return [];
            }
        }

        return [];
    }

    /**
     * Every Page the token can publish from, following Meta's paging.
     *
     * This asked for 50 and stopped, so an account with more than fifty Pages
     * simply never saw the rest and the buyer was told their Page did not
     * exist. Meta returns a cursor rather than a total, so the only way to know
     * there are more is to follow it.
     *
     * Bounded at 500 because a chooser is not a directory, and an unbounded
     * loop against someone else's API is a way to hang a web request.
     *
     * @return list<array<string,mixed>>
     */
    public function pages(int $limit = 50): array
    {
        $pages = [];
        $after = null;

        do {
            $response = $this->get('me/accounts', array_filter([
                'fields' => 'id,name,category',
                'limit' => $limit,
                'after' => $after,
            ], fn (mixed $value): bool => $value !== null && $value !== ''));

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

        return $pages;
    }

    /**
     * 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) {
            // Both Page fields, because Meta keeps two and they can disagree.
            //
            // connected_instagram_account is the older linkage;
            // instagram_business_account is the Business or Creator account
            // linked in Page settings, and is the one Meta's own ads
            // documentation points at. A Page can carry the second without the
            // first, and reading only one field reported no Instagram presence
            // for a Page that visibly has one.
            //
            // Asked for in the same request, so this costs a field rather than
            // a call, and the pair is deduplicated by id below.
            $connected = $this->get($pageId, [
                'fields' => 'connected_instagram_account{id,username},instagram_business_account{id,username}',
            ]);

            foreach (['connected_instagram_account', 'instagram_business_account'] as $field) {
                $id = data_get($connected, $field.'.id');

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

                $accounts[$id] = [
                    'id' => (string) $id,
                    'username' => data_get($connected, $field.'.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, ?string $adAccountId = null): 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',
        ], $adAccountId);
    }

    /**
     * Which account a campaign belongs to, and nothing else about it.
     *
     * A campaign addressed by its own id cannot be checked against the allow
     * list before the request, because there is no account in the path to check.
     * That is unavoidable - but reading its budget, objective, schedule and bid
     * strategy on the way to finding out is not. This asks for the one field the
     * decision needs, so a campaign on an account we are not permitted to touch
     * never has its settings read, returned, or written into the call log.
     */
    public function campaignAccountId(string $campaignId): ?string
    {
        $accountId = $this->get($campaignId, ['fields' => 'account_id'])['account_id'] ?? null;

        return filled($accountId) ? (string) $accountId : null;
    }

    /**
     * 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, ?string $adAccountId = null): 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,
        ], $adAccountId)['data'] ?? [];
    }

    /**
     * Ad sets with delivery and learning signals for performance ingest.
     *
     * @return list<array<string, mixed>>
     */
    public function performanceAdSets(string $campaignId, int $limit = 50, ?string $adAccountId = null): 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,
        ], $adAccountId)['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, ?string $adAccountId = null): 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 !== ''), $adAccountId);

            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>>
     */
    /**
     * What a pixel has actually been firing, most frequent first.
     *
     * The ad set optimises toward one event, and until now that event was
     * inferred from the objective alone: leads meant LEAD whether or not the
     * pixel had ever seen a Lead. Kaushal's pixel 1017787001151608 fires
     * PageView and Subscribe and nothing else, and campaign 120252047635070626
     * is live asking Meta to optimise toward LEAD, which it can never observe.
     *
     * stats is bucketed hourly over the reporting window, so the counts are
     * summed per event name rather than read from one bucket. An empty result is
     * ordinary: a pixel installed an hour ago has no history, and that is not a
     * reason to refuse.
     *
     * @return array<string,int> event name => times seen
     */
    public function pixelEvents(string $pixelId): array
    {
        $totals = [];

        foreach ($this->get($pixelId.'/stats', ['aggregation' => 'event'])['data'] ?? [] as $bucket) {
            foreach ($bucket['data'] ?? [] as $row) {
                $name = (string) ($row['value'] ?? '');

                if ($name === '') {
                    continue;
                }

                $totals[$name] = ($totals[$name] ?? 0) + (int) ($row['count'] ?? 0);
            }
        }

        arsort($totals);

        return $totals;
    }

    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(
                // Exempt from both checks, and deliberately.
                //
                // The kill switch is paused-writes, and a rollback has to be
                // able to finish while writes are paused or a half-made
                // campaign stays half-made. The allow list is exempt for a
                // different reason: deleting is the safe direction. Refusing to
                // remove an object because its account was revoked after we
                // created it would strand a paid object with no way to clean it
                // up, which is worse than the read it protects against.
                new CallContext('graph', 'DELETE', $objectId, mutating: true, guarded: false, accountChecked: false),
                fn (): array => $this->unwrap(
                    Http::timeout(60)->delete($this->url($objectId), ['access_token' => $this->token]),
                    $objectId,
                ),
            );

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

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

    /**
     * @param  ?string  $adAccountId  the account this read belongs to, when the
     *                                endpoint does not name it
     */
    private function get(string $endpoint, array $query = [], ?string $adAccountId = null): array
    {
        return $this->log->record(
            // accountIn() only finds an account when the endpoint starts with
            // one, so every read addressed by object id - a campaign, an ad
            // set, insights - reached the API with no account and skipped the
            // allow list entirely. assertAccountAllowed()'s own docblock says
            // reads are included, "an account we may not spend on is an account
            // we have no business reading either", and they were not.
            new CallContext('graph', 'GET', $endpoint, $adAccountId ?? $this->accountIn($endpoint), $query),
            fn (): array => $this->unwrap(
                Http::timeout(60)->get($this->url($endpoint), [...$query, 'access_token' => $this->token]),
                $endpoint,
            ),
        );
    }

    /**
     * Create a pixel on an ad account.
     *
     * Meta renamed these to "datasets" in Events Manager and left the API
     * alone, so the buyer may well ask for a dataset and mean this exactly.
     * There is no /owned_data_sets path; adspixels is still the only one.
     *
     * Created on the ad account rather than the business. Both endpoints
     * accept, but the account is the thing in hand and the one the campaign
     * already publishes to; a business-owned pixel is the wider choice and
     * wants a deliberate decision rather than a default.
     *
     * @return array<string, mixed>
     */
    public function createPixel(string $adAccountId, string $name): array
    {
        return $this->post($this->prefixed($adAccountId).'/adspixels', ['name' => $name]);
    }

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