<?php

namespace App\Services\LinkedIn;

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

class LinkedInRestApi
{
    private const BASE_URL = 'https://api.linkedin.com';

    public function __construct(
        private readonly string $token,
        private readonly string $version,
        private readonly CallLogger $log,
    ) {}

    /**
     * List accessible ad accounts.
     *
     * @return list<array<string,mixed>>
     */
    public function adAccounts(): array
    {
        $response = $this->get('rest/adAccounts?q=search');

        return $response['elements'] ?? [];
    }

    /**
     * Get a specific ad account.
     */
    public function adAccount(string $adAccountId): array
    {
        $id = LinkedInUrn::bare($adAccountId);

        return $this->get("rest/adAccounts/{$id}");
    }

    /**
     * Create a campaign group (container for campaigns).
     *
     * @param  array<string,mixed>  $payload
     * @return array<string,mixed>
     */
    public function createCampaignGroup(string $adAccountId, array $payload): array
    {
        $bareId = LinkedInUrn::bare($adAccountId);
        $accountUrn = LinkedInUrn::account($adAccountId);

        $body = [
            'account' => $accountUrn,
            'name' => $payload['name'],
            // DRAFT, not PAUSED, and not because PAUSED is unsafe.
            //
            // This defaulted to ACTIVE and skipped the guard entirely, so the
            // one object that decides whether everything beneath it can deliver
            // was the one object the kill switch never saw. Setting PAUSED
            // fixed that and broke creation outright: LinkedIn refuses the
            // transition with "/CampaignGroup/status cannot be changed from
            // null to PAUSED", because a group is created DRAFT or ACTIVE and
            // only moves to PAUSED afterwards. Every LinkedIn publish failed
            // on it, which no Http::fake could show.
            //
            // DRAFT is the stricter of the two anyway: a paused group can be
            // resumed, a draft one has never been live at all.
            'status' => 'DRAFT',
            'runSchedule' => [
                'start' => (int) (now()->timestamp * 1000),
            ],
            ...$payload,
        ];

        // DRAFT is already the safe end of this scale, so the guard is asked to
        // hold it there rather than push it to a status LinkedIn will not take
        // on creation.
        $this->ensureSafeStatus($body, 'DRAFT');

        return $this->post("rest/adAccounts/{$bareId}/adCampaignGroups", $body, $adAccountId);
    }

    /**
     * Create a campaign (holds targeting, schedule, budget, and objective).
     *
     * @param  array<string,mixed>  $payload
     * @return array<string,mixed>
     */
    public function createCampaign(string $adAccountId, array $payload): array
    {
        $bareId = LinkedInUrn::bare($adAccountId);
        $accountUrn = LinkedInUrn::account($adAccountId);

        $body = [
            'account' => $accountUrn,
            'name' => $payload['name'],
            'status' => 'PAUSED',
            'runSchedule' => [
                'start' => (int) (now()->timestamp * 1000),
            ],
            'locale' => [
                'country' => 'US',
                'language' => 'en',
            ],
            'offsiteDeliveryEnabled' => false,
            'politicalIntent' => 'NOT_POLITICAL',
            ...$payload,
        ];

        $this->ensureSafeStatus($body);

        return $this->post("rest/adAccounts/{$bareId}/adCampaigns", $body, $adAccountId);
    }

    /**
     * Upload an image to LinkedIn and return the image URN (e.g. urn:li:image:xxx).
     */
    public function uploadImage(string $adAccountId, string $filePath, ?string $name = null, ?string $ownerUrn = null): string
    {
        $orgId = config('platforms.linkedin.organization_id');
        $owner = $ownerUrn
            ?? ($orgId ? LinkedInUrn::organization((string) $orgId) : LinkedInUrn::account($adAccountId));

        $initializeUploadRequest = [
            'owner' => $owner,
        ];

        if (str_starts_with($owner, 'urn:li:organization:') && ! empty($adAccountId)) {
            $initializeUploadRequest['mediaLibraryMetadata'] = [
                'associatedAccount' => LinkedInUrn::account($adAccountId),
            ];
            if (! empty($name)) {
                $initializeUploadRequest['mediaLibraryMetadata']['assetName'] = $name;
            }
        }

        $initResponse = $this->post('rest/images?action=initializeUpload', [
            'initializeUploadRequest' => $initializeUploadRequest,
        ], $adAccountId);

        $uploadUrl = $initResponse['value']['uploadUrl'] ?? null;
        $imageUrn = $initResponse['value']['image'] ?? null;

        if (! $uploadUrl || ! $imageUrn) {
            throw new LinkedInException('Failed to initialize image upload on LinkedIn.');
        }

        // Checked before reading. file_get_contents() on a missing path warns
        // and returns false, and Http::withBody(false) uploads an empty body,
        // so LinkedIn was handed a zero byte image and the ad was built around
        // it. The same guard is on GraphApi::multipart() for the same reason.
        if (! is_file($filePath)) {
            throw new LinkedInException(sprintf(
                'The file for %s is missing, so it could not be uploaded to LinkedIn.',
                $name ?: basename($filePath),
            ));
        }

        $binary = file_get_contents($filePath);
        $mime = mime_content_type($filePath) ?: 'application/octet-stream';
        $put = Http::withBody($binary, $mime)->put($uploadUrl);

        if (! $put->successful()) {
            throw new LinkedInException("Failed to upload image binary to LinkedIn: {$put->status()} {$put->body()}");
        }

        return $imageUrn;
    }

    /**
     * Create a sponsored creative / ad.
     *
     * @param  array<string,mixed>  $payload
     * @return array<string,mixed>
     */
    public function createCreative(string $adAccountId, array $payload): array
    {
        $bareId = LinkedInUrn::bare($adAccountId);

        $body = [
            ...$payload,
            'intendedStatus' => 'ACTIVE',
        ];

        unset($body['status']);

        // intendedStatus is the creative's own switch, and it was set to ACTIVE
        // unconditionally while the guard below it only ever looked at status.
        $this->ensureSafeStatus($body);

        return $this->post("rest/adAccounts/{$bareId}/creatives", $body, $adAccountId);
    }

    /**
     * Create a post or Direct Sponsored Content (dark post).
     *
     * @param  array<string,mixed>  $payload
     * @return array<string,mixed>
     */
    public function createPost(array $payload, ?string $adAccountId = null): array
    {
        // A Direct Sponsored Content post is a real post on a real Page. It
        // went out with lifecycleState PUBLISHED, visibility PUBLIC and
        // dscStatus ACTIVE with nothing checking the switch, which made
        // LINKEDIN_PUBLISH_ENABLED=false the safest sounding setting that did
        // not stop the most public thing this client does.
        $this->ensureSafeStatus($payload);

        return $this->post('rest/posts', $payload, $adAccountId);
    }

    /**
     * Update an entity (campaign, campaign group, or creative).
     *
     * @param  array<string,mixed>  $patchFields
     * @return array<string,mixed>
     */
    public function updateEntity(string $adAccountId, string $entityType, string $entityId, array $patchFields): array
    {
        $bareAccountId = LinkedInUrn::bare($adAccountId);
        $id = LinkedInUrn::bare($entityId);

        if (! config('platforms.linkedin.publish_enabled', false) && ($patchFields['status'] ?? null) === 'ACTIVE') {
            Log::info("Blocked setting LinkedIn entity {$entityType}/{$id} to ACTIVE because LINKEDIN_PUBLISH_ENABLED is false.");
            $patchFields['status'] = 'PAUSED';
        }

        $creativeKey = urlencode(LinkedInUrn::creative($id));
        $endpoint = match ($entityType) {
            'campaign_group' => "rest/adAccounts/{$bareAccountId}/adCampaignGroups/{$id}",
            'campaign' => "rest/adAccounts/{$bareAccountId}/adCampaigns/{$id}",
            'creative' => "rest/adAccounts/{$bareAccountId}/creatives/{$creativeKey}",
            default => "rest/adAccounts/{$bareAccountId}/{$entityType}/{$id}",
        };

        return $this->post($endpoint, ['patch' => ['$set' => $patchFields]], $adAccountId);
    }

    /**
     * Nothing goes live while publishing is switched off.
     *
     * This looked only at `status`, and only if the key was already there. Two
     * of the three things this client creates do not use that key: a creative
     * carries `intendedStatus` and a Direct Sponsored Content post carries
     * `lifecycleState` plus `adContext.dscStatus`. So with
     * LINKEDIN_PUBLISH_ENABLED=false a campaign was correctly paused while the
     * creative under it went out ACTIVE and the post went out PUBLISHED and
     * PUBLIC on the company Page.
     *
     * Every key that decides whether something is live is listed here, and it
     * is applied to every create rather than to the one that happened to be
     * remembered. A switch that covers most of the ways to go live is not a
     * switch.
     */
    private function ensureSafeStatus(array &$payload, string $safeStatus = 'PAUSED'): void
    {
        if (config('platforms.linkedin.publish_enabled', false)) {
            return;
        }

        $safe = [
            'status' => $safeStatus,
            'intendedStatus' => 'DRAFT',
            'lifecycleState' => 'DRAFT',
        ];

        foreach ($safe as $key => $value) {
            if (array_key_exists($key, $payload)) {
                $payload[$key] = $value;
            }
        }

        // The post's own switch lives one level down, and PUBLISHED there is
        // what puts it on the Page whatever the rest of the payload says.
        if (array_key_exists('dscStatus', (array) ($payload['adContext'] ?? []))) {
            $payload['adContext']['dscStatus'] = 'DRAFT';
        }
    }

    /**
     * Delete or cancel an entity.
     */
    public function delete(string $adAccountId, string $entityType, string $entityId): bool
    {
        $bareAccountId = LinkedInUrn::bare($adAccountId);
        $id = LinkedInUrn::bare($entityId);
        $creativeKey = urlencode(LinkedInUrn::creative($id));
        $endpoint = match ($entityType) {
            'campaign_group' => "rest/adAccounts/{$bareAccountId}/adCampaignGroups/{$id}",
            'campaign' => "rest/adAccounts/{$bareAccountId}/adCampaigns/{$id}",
            'creative' => "rest/adAccounts/{$bareAccountId}/creatives/{$creativeKey}",
            default => "rest/adAccounts/{$bareAccountId}/{$entityType}/{$id}",
        };

        $result = $this->deleteCall($endpoint, $adAccountId);

        return ($result['status'] ?? 200) < 300;
    }

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

    private function get(string $endpoint, array $query = [], ?string $adAccountId = null): array
    {
        return $this->log->record(
            new CallContext('linkedin_rest', 'GET', $endpoint, $adAccountId, $query, mutating: false, platform: 'linkedin'),
            fn (): array => $this->unwrap(
                Http::timeout(60)
                    ->withToken($this->token)
                    ->withHeaders($this->headers(hasBody: false))
                    ->get($this->url($endpoint), ! empty($query) ? $query : null),
                $endpoint,
            ),
        );
    }

    private function post(string $endpoint, array $data, ?string $adAccountId = null): array
    {
        return $this->log->record(
            new CallContext('linkedin_rest', 'POST', $endpoint, $adAccountId, $data, mutating: true, platform: 'linkedin'),
            fn (): array => $this->unwrap(
                Http::timeout(120)
                    ->withToken($this->token)
                    ->withHeaders($this->headers(hasBody: true))
                    ->post($this->url($endpoint), $data),
                $endpoint,
            ),
        );
    }

    private function deleteCall(string $endpoint, ?string $adAccountId = null): array
    {
        return $this->log->record(
            new CallContext('linkedin_rest', 'DELETE', $endpoint, $adAccountId, [], mutating: true, platform: 'linkedin'),
            fn (): array => $this->unwrap(
                Http::timeout(60)
                    ->withToken($this->token)
                    ->withHeaders($this->headers(hasBody: false))
                    ->delete($this->url($endpoint)),
                $endpoint,
            ),
        );
    }

    private function unwrap(Response $response, string $endpoint): array
    {
        if ($response->status() === 204) {
            return ['status' => 204];
        }

        // LinkedIn 201 Created returns entity ID in x-restli-id header
        if ($response->status() === 201) {
            $createdId = $response->header('x-restli-id') ?? $response->header('x-linkedin-id');
            $body = $response->json();

            if (is_array($body)) {
                return ['id' => $createdId, ...$body];
            }

            return ['id' => $createdId];
        }

        $body = $response->json();

        if (! is_array($body)) {
            if ($response->successful()) {
                return ['status' => $response->status()];
            }

            throw new LinkedInException(sprintf('HTTP %d response from LinkedIn on %s', $response->status(), $endpoint));
        }

        // The HTTP status decides, not the presence of a word.
        //
        // This also threw whenever the body carried a `message` key, and
        // `message` is ordinary content on LinkedIn: a post has one, and so
        // does an InMail creative. A successful 201 from rest/posts was being
        // turned into an exception because the thing we had just created
        // contained the text we asked it to contain.
        //
        // An `error` key on a successful response is still worth trusting,
        // because LinkedIn does occasionally answer 200 with one.
        if ($response->failed() || isset($body['error'])) {
            $message = $body['message'] ?? $body['error']['message'] ?? $body['error'] ?? 'Unknown LinkedIn error';
            $code = $body['code'] ?? $body['status'] ?? $response->status();

            throw new LinkedInException(sprintf(
                '%s [LinkedIn %s] on %s',
                is_string($message) ? $message : json_encode($message),
                is_scalar($code) ? $code : $response->status(),
                $endpoint,
            ));
        }

        return $body;
    }

    private function url(string $endpoint): string
    {
        return self::BASE_URL.'/'.ltrim($endpoint, '/');
    }

    /**
     * @return array<string, string>
     */
    private function headers(bool $hasBody = false): array
    {
        $headers = [
            'LinkedIn-Version' => $this->version,
            'X-Restli-Protocol-Version' => '2.0.0',
            'Accept' => 'application/json',
        ];

        if ($hasBody) {
            $headers['Content-Type'] = 'application/json';
        }

        return $headers;
    }
}
