<?php

namespace App\Services\LinkedIn;

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

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>>
     */
    /**
     * An id LinkedIn can actually parse, or a refusal that says so.
     *
     * Every id in this API is a number and every path is built from one. Nothing
     * checked, so a Meta-style act_ id, or an account name typed instead of an id,
     * went into the URL and LinkedIn answered "Failed to convert ... to
     * java.lang.Long" - a Java type error, in front of a media buyer, about a value
     * they could have fixed in a second if anything had said which value it was.
     *
     * Refused here as well as in the tool, because a path built from an unchecked
     * id is a class of bug rather than one call site, and eight of them in this file
     * had it.
     */
    private function numeric(string $id, string $what): string
    {
        return LinkedInUrn::numericId($id) ?? throw new LinkedInException(sprintf(
            '[%s] is not a LinkedIn %s id. LinkedIn ids are numbers, on their own or as a urn.',
            $id,
            $what,
        ));
    }

    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
    {
        return $this->get('rest/adAccounts/'.$this->numeric($adAccountId, 'ad account'));
    }

    /** @return list<array<string, mixed>> */
    public function countryCandidates(string $countryName, ?string $adAccountId = null): array
    {
        $response = $this->get('rest/adTargetingEntities', [
            'q' => 'typeahead',
            'facet' => 'urn:li:adTargetingFacet:locations',
            'queryVersion' => 'QUERY_USES_URNS',
            'query' => $countryName,
        ], $adAccountId);

        return (array) ($response['elements'] ?? []);
    }

    /**
     * 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 = $this->numeric($adAccountId, 'ad account');
        $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,
        ];

        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 = $this->numeric($adAccountId, 'ad account');
        $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,
        ];

        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 = PlatformConnection::sharedMetadata('linkedin', 'organization_id', 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 = $this->numeric($adAccountId, 'ad account');

        // The caller's own value, and DRAFT when it has not said.
        //
        // This forced ACTIVE over whatever it was given, and relied on the removed
        // ensureSafeStatus() to pull it back down - so the adapter's intent was
        // overwritten twice on the way out, once in each direction. Every creative
        // this product builds is a draft until an approved activation says
        // otherwise, so that is the default; overriding the caller is not this
        // layer's job either way.
        $body = [
            'intendedStatus' => 'DRAFT',
            ...$payload,
        ];

        unset($body['status']);

        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
    {
        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 = $this->numeric($adAccountId, 'ad account');
        $id = $this->numeric($entityId, 'entity');

        $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 here rewrites what a caller asked for.
     *
     * There was a guard that quietly turned ACTIVE into PAUSED and PUBLISHED into
     * DRAFT on the way out, and updateEntity did the same for status. It was doing
     * a real job badly: the adapter above asked for ACTIVE, believed it had got it,
     * and something invisible disagreed. While that rewrite did not yet cover
     * lifecycleState, a real sponsored post went out publicly with the campaign
     * over it correctly paused - exactly the failure a silent correction invites,
     * because nobody can see which cases it is not covering.
     *
     * The adapter builds draft now, because that is what building means, and going
     * live is an approved decision with a tool behind it. A guard that refuses is a
     * guard; one that edits your payload and says nothing is a second author.
     */

    /**
     * Delete or cancel an entity.
     */
    public function delete(string $adAccountId, string $entityType, string $entityId): bool
    {
        $bareAccountId = $this->numeric($adAccountId, 'ad account');
        $id = $this->numeric($entityId, 'entity');
        $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;
    }
}
