<?php

namespace App\Services\Meta;

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'] ?? [];
    }

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

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

    public function createCampaign(string $adAccountId, string $name, string $objective, bool $dryRun = false): array
    {
        return $this->post($this->prefixed($adAccountId).'/campaigns', array_filter([
            'name' => $name,
            'objective' => $objective,
            'status' => 'PAUSED',
            'buying_type' => 'AUCTION',
            'special_ad_categories' => json_encode([]),
            // Required whenever the budget sits on the ad set rather than the
            // campaign. Omitting it fails with subcode 4834011.
            'is_adset_budget_sharing_enabled' => 'false',
            '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,
            'daily_budget' => $spec->dailyBudgetCents,
            'billing_event' => 'IMPRESSIONS',
            'optimization_goal' => $spec->optimizationGoal,
            'bid_strategy' => $spec->bidStrategy,
            'bid_amount' => $spec->bidAmountCents,
            'targeting' => json_encode($spec->targetingSpec()),
            'promoted_object' => json_encode($spec->promotedObject()),
            'status' => 'PAUSED',
        ], 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()),
        ]);
    }

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

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

    private function multipart(string $endpoint, string $field, string $path, string $filename, int $timeout): array
    {
        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;
    }
}
