<?php

namespace App\Services\Platforms;

use App\Campaigns\Platforms\LinkedInRules;
use App\Campaigns\Targeting\CountryCatalog;
use App\Campaigns\TrackedUrl;
use App\Contracts\PlatformAdapter;
use App\Exceptions\ProviderNotConfiguredException;
use App\Models\AdCopy;
use App\Models\Asset;
use App\Models\AssetPlatformRef;
use App\Models\Campaign;
use App\Models\CampaignAd;
use App\Models\Creative;
use App\Models\PlatformConnection;
use App\Services\LinkedIn\LinkedIn;
use App\Services\LinkedIn\LinkedInException;
use App\Services\LinkedIn\LinkedInUrn;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Throwable;

class LinkedInAdapter implements PlatformAdapter
{
    public function __construct(
        private readonly ?LinkedIn $linkedIn = null,
        private readonly ?CountryCatalog $countries = null,
    ) {}

    public function createCampaign(Campaign $campaign): string
    {
        if (! empty($campaign->external_campaign_id)) {
            return $campaign->external_campaign_id;
        }

        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
        $adAccountId = $this->adAccountId($campaign);

        $prefixedName = $this->prefixedName($campaign);
        $targetingCriteria = $this->buildTargetingCriteria($campaign);

        // 1. Create Campaign Group if needed or use existing.
        //
        // Remembered in external_ids, which is a json column that already
        // exists, rather than external_campaign_group_id, which does not: that
        // property is not a column, is not fillable and is written nowhere, so
        // it read null every time and every publish attempt minted another
        // group. A failed publish therefore left one behind and the retry made
        // a second, with nothing pointing at either.
        $campaignGroupId = $campaign->external_ids['campaign_group_id'] ?? null;

        if (blank($campaignGroupId)) {
            $campaignGroupId = $linkedIn->createCampaignGroup($adAccountId, "{$prefixedName} Group");

            // Recorded before the campaign under it is attempted, so a failure
            // on the next call still leaves something pointing at the group.
            $campaign->update([
                'external_ids' => [...(array) ($campaign->external_ids ?? []), 'campaign_group_id' => $campaignGroupId],
            ]);
        }

        // The buyer's own answer, refused rather than replaced when missing.
        //
        // This read ($campaign->budget ?? 20.0), so a campaign that reached
        // here without one was given twenty a day that nobody had asked for or
        // seen. A budget is the one field where inventing a default spends
        // somebody's money, and the publish gate already refuses a campaign
        // with no budget, so the fallback could only ever fire on a path that
        // had gone wrong.
        if (blank($campaign->budget)) {
            throw new LinkedInException(
                'This campaign has no budget, so there is nothing to send LinkedIn. Set one and try again.'
            );
        }

        // 2. Create Campaign within that Group
        $campaignId = $linkedIn->createCampaign($adAccountId, array_filter([
            'name' => $prefixedName,
            'campaignGroup' => LinkedInUrn::campaignGroup($campaignGroupId),
            'status' => 'PAUSED',
            'type' => 'SPONSORED_UPDATES',
            'objectiveType' => $this->objectiveType($campaign->objective),
            // The buyer's own bid strategy, not CPM for everyone.
            //
            // LinkedInRules offers three strategies and the flow asks "How
            // should it bid?", and every answer was thrown away here: costType
            // was CPM whatever they picked, so the question cost a turn and
            // changed nothing.
            ...$this->biddingFor($campaign),
            // Lifetime and daily are different fields on LinkedIn as they are
            // on Meta, and everything went out as dailyBudget. A buyer who set
            // five hundred to spend over a fortnight got five hundred a day,
            // which is the same mistake MetaPublisher already avoids by asking
            // usesLifetimeBudget().
            ...$this->budgetFor($campaign),
            'targetingCriteria' => $targetingCriteria,
            // Three fields LinkedIn requires and this never sent, so every
            // campaign create came back 422 listing them. Found by posting the
            // adapter's own payload at the live API and reading what it asked
            // for, because the request never got far enough for anything here
            // to report them.
            'locale' => $this->localeFor($campaign),
            'politicalIntent' => 'NOT_POLITICAL',
            // false is the usual answer here and array_filter() strips exactly
            // that, so this is required, sent, and silently removed on the way
            // out. The filter now keeps anything that is not null, which is
            // what it meant all along.
            'offsiteDeliveryEnabled' => in_array('linkedin_audience_network', (array) ($campaign->placements ?? []), true),
        ], fn (mixed $value): bool => $value !== null));

        return $campaignId;
    }

    /**
     * The campaign's own locale, which is not the audience's languages.
     *
     * localesFor() builds the interfaceLocales facet, a targeting choice.
     * This is the campaign's own, required at creation, and LinkedIn wants the
     * two halves separately rather than as a urn.
     *
     * @return array{country: string, language: string}
     */
    private function localeFor(Campaign $campaign): array
    {
        $countries = array_values(array_filter(
            (array) ($campaign->locations['countries'] ?? []),
            is_string(...),
        ));

        $country = strtoupper(trim((string) ($countries[0] ?? 'US')));

        $language = match ($country) {
            'DE' => 'de', 'FR' => 'fr', 'ES', 'MX' => 'es', 'IT' => 'it',
            'NL' => 'nl', 'BR' => 'pt', 'JP' => 'ja', 'SE' => 'sv',
            default => 'en',
        };

        return ['country' => $country, 'language' => $language];
    }

    /**
     * Bidding, in the two fields LinkedIn splits it across.
     *
     * costType says what is being bought and bidStrategy says how hard to bid
     * for it. Automated delivery lets LinkedIn set the number; the other two
     * carry the buyer's own, which the flow asks for and
     * bidStrategyNeedsTarget() already says is required.
     *
     * @return array<string, mixed>
     */
    private function biddingFor(Campaign $campaign): array
    {
        $strategy = $campaign->bid_strategy ?: 'AUTOMATED_BID';

        // Cost per click where a click is the point, impressions otherwise.
        // Sending CPM on a traffic campaign buys reach and calls it traffic.
        $costType = in_array($campaign->objective, ['WEBSITE_VISITS', 'WEBSITE_CONVERSIONS', 'LEAD_GENERATION'], true)
            ? 'CPC'
            : 'CPM';

        // costType and unitCost are the whole of LinkedIn's bidding model.
        //
        // This also sent bidStrategy, and the API has no such field: it answers
        // "ERROR :: /bidStrategy :: unrecognized field found but not allowed"
        // and rejects the campaign outright, so no LinkedIn campaign could be
        // created at all. Confirmed against the live API, where removing the
        // field is what clears the rejection.
        //
        // The buyer's choice is not lost with it, because LinkedIn expresses
        // the same thing through the two fields it does take: automated bidding
        // sends no unitCost and lets LinkedIn bid, and a cap or a target sends
        // one. The strategy name stays on the campaign for the panel and the
        // gate to read.
        $bidding = ['costType' => $costType];

        if ($strategy === 'AUTOMATED_BID') {
            return $bidding;
        }

        if (blank($campaign->bid_amount)) {
            throw new LinkedInException(sprintf(
                '%s bidding needs a number to bid, and this campaign has none. Set one and try again.',
                $strategy,
            ));
        }

        $bidding['unitCost'] = [
            'amount' => (string) $campaign->bid_amount,
            'currencyCode' => $campaign->currency ?: 'USD',
        ];

        return $bidding;
    }

    /**
     * The budget, under the key LinkedIn expects for its kind.
     *
     * A lifetime budget also needs an end, because LinkedIn reads it as the
     * total to spend by then and refuses one without a date. The flow asks for
     * that end date whenever the budget is a lifetime one, so by the time a
     * campaign reaches here it has one, and saying why is more use than a
     * rejection from the API.
     *
     * @return array<string, mixed>
     */
    /**
     * The name LinkedIn holds, which is not quite the name we hold.
     *
     * Shared with createCampaign because the two had to agree and only one of
     * them knew about the prefix. An update sending the bare name would have
     * quietly renamed every campaign from ARB-Q4 to Q4 the first time anything
     * else about it was edited.
     */
    private function prefixedName(Campaign $campaign): string
    {
        return str_starts_with((string) $campaign->name, 'ARB-')
            ? (string) $campaign->name
            : 'ARB-'.$campaign->name;
    }

    /**
     * Send an approved edit to the live campaign.
     *
     * LinkedInPublisher::update() was an empty method body under a comment
     * reading "Live updates for LinkedIn". Every field LinkedInRules declares
     * editable after publish was accepted, recorded as applied, and never sent,
     * so the campaign on LinkedIn kept its original budget and dates while our
     * snapshot said otherwise, and the buyer was told the change had gone
     * through. PlatformPublisher says this outright: a field declared editable
     * and silently not sent is worse than one refused outright.
     *
     * Everything editable lives on the campaign entity, so this is one PATCH.
     *
     * costType is deliberately not sent even when the bidding changes.
     * LinkedIn fixes how a campaign is charged at launch and rejects the
     * change, which would fail the whole patch and take the budget edit down
     * with it.
     *
     * runSchedule only goes when a date actually changed, because it carries
     * the start as well as the end and LinkedIn refuses to move the start of a
     * campaign that is already running.
     *
     * @param  array<string, array{from: mixed, to: mixed}>  $changes
     * @return array<string, mixed> what was sent, for the caller to record
     */
    public function updateCampaign(Campaign $campaign, array $changes): array
    {
        if (blank($campaign->external_campaign_id)) {
            throw new LinkedInException(
                'This campaign has not been published to LinkedIn, so there is nothing live to update.'
            );
        }

        $patch = [];

        foreach (array_keys($changes) as $field) {
            match ($field) {
                'name' => $patch['name'] = $this->prefixedName($campaign),
                'budget', 'budget_mode' => $patch = [...$patch, ...$this->budgetPatch($campaign)],
                'bid_strategy', 'bid_amount' => $patch = [...$patch, ...$this->bidPatch($campaign)],
                'starts_at', 'ends_at' => $patch['runSchedule'] = $this->runSchedule($campaign),
                // Declared editable somewhere and not handled here is the exact
                // failure this method exists to stop, so it is loud.
                default => throw new LinkedInException(sprintf(
                    'LinkedIn cannot change %s on a published campaign, so the edit was not sent.',
                    $field,
                )),
            };
        }

        if ($patch === []) {
            return [];
        }

        ($this->linkedIn ?? app(LinkedIn::class))->updateCampaign(
            $this->adAccountId($campaign),
            $campaign->external_campaign_id,
            $patch,
        );

        return $patch;
    }

    /**
     * The budget alone, without the dates budgetFor() carries for creation.
     *
     * @return array<string, mixed>
     */
    private function budgetPatch(Campaign $campaign): array
    {
        $budget = $this->budgetFor($campaign);
        unset($budget['runSchedule']);

        return $budget;
    }

    /**
     * The bid alone, without the costType LinkedIn fixes at launch.
     *
     * @return array<string, mixed>
     */
    private function bidPatch(Campaign $campaign): array
    {
        $bidding = $this->biddingFor($campaign);
        unset($bidding['costType']);

        return $bidding;
    }

    private function budgetFor(Campaign $campaign): array
    {
        $currency = $campaign->currency ?: 'USD';
        $amount = ['amount' => (string) $campaign->budget, 'currencyCode' => $currency];

        // A daily budget still has dates. runSchedule was built only for the
        // lifetime branch, so a campaign with a daily budget and an end date
        // was sent without one and ran until somebody noticed and paused it.
        // The end date is the whole of what "runs until the 30th" means, and
        // losing it costs real money rather than failing.
        if (! $campaign->usesLifetimeBudget()) {
            return [
                'dailyBudget' => $amount,
                'runSchedule' => $this->runSchedule($campaign),
            ];
        }

        if (blank($campaign->ends_at)) {
            throw new LinkedInException(
                'A lifetime budget is the total to spend by a date, and this campaign has no end date. '
                .'Set one and try again.'
            );
        }

        return [
            'totalBudget' => $amount,
            'runSchedule' => $this->runSchedule($campaign),
        ];
    }

    /**
     * The dates LinkedIn runs this between, in milliseconds.
     *
     * One builder for both budget kinds, because they had drifted: the
     * lifetime branch sent start and end while the daily branch sent neither,
     * and the difference was an accident of which one was written first rather
     * than anything LinkedIn requires.
     *
     * An absent end is left out rather than sent as null. LinkedIn reads a
     * missing end as "runs until stopped", which is what a campaign with no end
     * date means, and rejects the key set to null.
     *
     * @return array{start: int, end?: int}
     */
    private function runSchedule(Campaign $campaign): array
    {
        $schedule = ['start' => (int) (($campaign->starts_at ?? now())->timestamp * 1000)];

        if ($campaign->ends_at) {
            $schedule['end'] = (int) ($campaign->ends_at->timestamp * 1000);
        }

        return $schedule;
    }

    public function createAd(Campaign $campaign, CampaignAd $ad): string
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
        $adAccountId = $this->adAccountId($campaign);
        $campaignUrn = LinkedInUrn::campaign($campaign->external_campaign_id);

        $adName = $ad->headline ?: "Ad {$ad->id}";
        $prefixedAdName = str_starts_with($adName, 'ARB-') ? $adName : "ARB-{$adName}";
        $reference = $this->resolveCreativeReference($campaign, $ad, $adAccountId);

        return $linkedIn->createCreative($adAccountId, [
            'campaign' => $campaignUrn,
            'name' => $prefixedAdName,
            // Built paused, said once, here.
            //
            // This asked for ACTIVE and relied on LinkedInRestApi rewriting it on
            // the way out. The code said one thing and the wire carried another,
            // which is the kind of arrangement that works until somebody reads the
            // adapter and believes it.
            'intendedStatus' => 'DRAFT',
            'content' => [
                'reference' => $reference,
            ],
        ]);
    }

    public function attachCreative(Campaign $campaign, Creative $creative, ?AdCopy $adCopy = null): string
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
        $adAccountId = $this->adAccountId($campaign);
        $campaignUrn = LinkedInUrn::campaign($campaign->external_campaign_id);

        $creativeName = $adCopy?->headline ?: ($creative->title ?: "Creative {$creative->id}");
        $prefixedName = str_starts_with($creativeName, 'ARB-') ? $creativeName : "ARB-{$creativeName}";

        // Resolve reference safely; never fabricate a local row id as a LinkedIn share URN.
        $reference = null;
        $asset = $creative->asset;
        if ($asset) {
            $assetRef = $asset->refFor('linkedin', $adAccountId);
            $rawId = $assetRef?->external_id;

            if ($rawId && str_starts_with($rawId, 'urn:li:share:')) {
                $reference = $rawId;
            } elseif ($rawId && str_starts_with($rawId, 'urn:li:image:')) {
                $ad = $campaign->ads()->first() ?? new CampaignAd(['headline' => $creativeName]);
                $reference = $this->createDirectSponsoredContent($campaign, $ad, $adAccountId, $rawId);
            } else {
                $orgId = $campaign->page_id ?: PlatformConnection::sharedMetadata('linkedin', 'organization_id', config('platforms.linkedin.organization_id'));
                $ownerUrn = $orgId ? LinkedInUrn::organization((string) $orgId) : null;
                $imageUrn = $this->uploadAssetImage($linkedIn, $adAccountId, $asset, $ownerUrn);
                if ($imageUrn) {
                    $ad = $campaign->ads()->first() ?? new CampaignAd(['headline' => $creativeName]);
                    $reference = $this->createDirectSponsoredContent($campaign, $ad, $adAccountId, $imageUrn);
                }
            }
        }

        if (blank($reference)) {
            throw new LinkedInException(sprintf(
                'Creative %d has no valid LinkedIn post or asset to sponsor. Attach a real creative with an image file and try again.',
                $creative->id,
            ));
        }

        return $linkedIn->createCreative($adAccountId, [
            'campaign' => $campaignUrn,
            'name' => $prefixedName,
            // Built paused; see the note on the other creative create above.
            'intendedStatus' => 'DRAFT',
            'content' => [
                'reference' => $reference,
            ],
        ]);
    }

    private function resolveCreativeReference(Campaign $campaign, CampaignAd $ad, string $adAccountId): string
    {
        $assetRef = $ad->asset?->refFor('linkedin', $adAccountId);
        $rawId = $assetRef?->external_id;

        // If the asset reference is an image URN, create a Direct Sponsored Content post for this ad
        if ($rawId && str_starts_with($rawId, 'urn:li:image:')) {
            return $this->createDirectSponsoredContent($campaign, $ad, $adAccountId, $rawId);
        }

        // If the asset has an image file, upload it if not yet an image URN, then create DSC post
        if ($ad->asset) {
            $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
            $orgId = $campaign->page_id ?: PlatformConnection::sharedMetadata('linkedin', 'organization_id', config('platforms.linkedin.organization_id'));
            $ownerUrn = $orgId ? LinkedInUrn::organization((string) $orgId) : null;
            $imageUrn = $this->uploadAssetImage($linkedIn, $adAccountId, $ad->asset, $ownerUrn);
            if ($imageUrn) {
                return $this->createDirectSponsoredContent($campaign, $ad, $adAccountId, $imageUrn);
            }
        }

        if ($rawId) {
            return $rawId;
        }

        // Refused rather than invented.
        //
        // This returned "urn:li:share:{$ad->id}", built from our own row id, so
        // ad 42 sponsored urn:li:share:42. That is not this ad's content: it is
        // whatever LinkedIn happens to have at that id, which is either nothing
        // or somebody else's post, and the ad would have been created pointing
        // at it. A reference we do not have is not a reference we can guess.
        throw new LinkedInException(sprintf(
            'Ad %d has nothing to sponsor on LinkedIn: no creative is attached and no post was created '
            .'for it. Attach a creative and try again.',
            $ad->id,
        ));
    }

    private function createDirectSponsoredContent(Campaign $campaign, CampaignAd $ad, string $adAccountId, string $imageUrn): string
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
        $orgId = $campaign->page_id ?: PlatformConnection::sharedMetadata('linkedin', 'organization_id', config('platforms.linkedin.organization_id'));
        $authorUrn = LinkedInUrn::organization((string) $orgId);

        $headline = $ad->headline ?: "Ad {$ad->id}";
        $commentary = $ad->primary_text ?: ($ad->headline ?: $campaign->name);
        $dscName = str_starts_with($headline, 'ARB-') ? $headline : "ARB-{$headline}";

        $destination = TrackedUrl::for($campaign);
        $cta = $ad->cta ?: 'LEARN_MORE';

        $payload = [
            'author' => $authorUrn,
            'commentary' => $commentary,
            'visibility' => 'PUBLIC',
            'distribution' => [
                'feedDistribution' => 'NONE',
                'thirdPartyDistributionChannels' => [],
            ],
            'content' => [
                'media' => [
                    'title' => $headline,
                    'id' => $imageUrn,
                ],
            ],
            'adContext' => [
                // The one status LinkedIn gives us no safe value for: it rejects
                // DRAFT and PAUSED as "not an enum symbol" and answers
                // CREATE_ARCHIVED_DSC_FORBIDDEN for ARCHIVED. lifecycleState below
                // is what decides whether the post reaches the Page, and that is
                // DRAFT.
                'dscStatus' => 'ACTIVE',
                'dscName' => $dscName,
                'dscAdAccount' => LinkedInUrn::account($adAccountId),
            ],
            // A draft post is not on the company Page. This said PUBLISHED and
            // was rewritten in the transport; when that rewrite did not yet cover
            // lifecycleState, a real post went out publicly while the campaign
            // above it sat correctly paused.
            'lifecycleState' => 'DRAFT',
            'isReshareDisabledByAuthor' => false,
        ];

        if (! empty($destination)) {
            $payload['contentLandingPage'] = $destination;
        }

        if (! empty($cta)) {
            $payload['contentCallToActionLabel'] = $cta;
        }

        try {
            $shareUrn = $linkedIn->createPost($payload, $adAccountId);
        } catch (Throwable $e) {
            // If the image was uploaded under an older ownership or mismatched owner,
            // re-upload it with the current organization URN and retry post creation.
            if (str_contains($e->getMessage(), 'not owned by the author') && $ad->asset) {
                try {
                    $newImageUrn = $this->uploadAssetImage($linkedIn, $adAccountId, $ad->asset, $authorUrn);
                    if ($newImageUrn) {
                        $payload['content']['media']['id'] = $newImageUrn;
                        $shareUrn = $linkedIn->createPost($payload, $adAccountId);
                        $imageUrn = $newImageUrn;
                    } else {
                        $shareUrn = null;
                    }
                } catch (Throwable) {
                    $shareUrn = null;
                }
            } else {
                $shareUrn = null;
            }

            if (empty($shareUrn)) {
                Log::error('Failed to create LinkedIn Direct Sponsored Content post', [
                    'campaign' => $campaign->id,
                    'ad' => $ad->id,
                    'error' => $e->getMessage(),
                ]);

                throw new LinkedInException(
                    "Cannot sponsor image ({$imageUrn}) on LinkedIn: {$e->getMessage()}. ".
                    "Ensure your LinkedIn organization ID ({$orgId}) is correct and the connected LinkedIn user has 'Sponsored Content Poster' or 'Paid Media Admin' permissions on the LinkedIn Page."
                );
            }
        }

        if (! empty($shareUrn)) {
            return $shareUrn;
        }

        throw new LinkedInException("LinkedIn post creation did not return a share URN for image {$imageUrn}.");
    }

    /**
     * Remove something this adapter created.
     *
     * Here rather than reaching past the adapter for the client, so the
     * publisher keeps one thing to talk to and the URN shapes stay in one
     * place.
     */
    public function delete(string $adAccountId, string $entityType, string $entityId): bool
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);

        return $linkedIn->delete($adAccountId, $entityType, $entityId);
    }

    /**
     * Start the whole tree: creatives, then the group, then the campaign.
     *
     * This was publish(), which set the campaign ACTIVE and returned early when
     * LINKEDIN_PUBLISH_ENABLED was false - so whether a campaign went live was
     * decided by an env var rather than by anyone, and the creatives under it were
     * never touched at all. A campaign ACTIVE over DRAFT creatives serves nothing.
     *
     * Deepest first, so a failure part way leaves the campaign still paused rather
     * than live over objects that are not.
     */
    public function activate(Campaign $campaign, array $creativeIds = []): void
    {
        $this->setStatus($campaign, 'ACTIVE', $creativeIds);
    }

    /** Stop at the campaign, which is where LinkedIn decides delivery. */
    public function pause(Campaign $campaign): void
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);

        $linkedIn->updateEntity(
            $this->adAccountId($campaign),
            'campaign',
            (string) $campaign->external_campaign_id,
            ['status' => 'PAUSED'],
        );
    }

    /** @param list<string> $creativeIds */
    private function setStatus(Campaign $campaign, string $status, array $creativeIds): void
    {
        $linkedIn = $this->linkedIn ?? app(LinkedIn::class);
        $adAccountId = $this->adAccountId($campaign);
        $ids = (array) ($campaign->external_ids ?? []);

        foreach ($creativeIds as $creativeId) {
            // A creative's own switch is spelled intendedStatus.
            $linkedIn->updateEntity($adAccountId, 'creative', (string) $creativeId, [
                'intendedStatus' => $status,
            ]);
        }

        if (filled($groupId = $ids['campaign_group_id'] ?? null)) {
            $linkedIn->updateEntity($adAccountId, 'campaign_group', (string) $groupId, ['status' => $status]);
        }

        $linkedIn->updateEntity($adAccountId, 'campaign', (string) $campaign->external_campaign_id, [
            'status' => $status,
        ]);
    }

    private function adAccountId(Campaign $campaign): string
    {
        // The campaign's own account, or the one LinkedIn is configured with.
        //
        // There was a third fallback: the first entry of
        // PLATFORM_ALLOWED_AD_ACCOUNTS. That list holds every platform's
        // accounts, so on this estate its first entry is a Meta one, and a
        // LinkedIn campaign with no account picked would have been published
        // against whatever happened to be at the top of a config string. Being
        // on the allow list means we may spend there, not that this campaign
        // should.
        $accountId = $campaign->ad_account_id ?: PlatformConnection::sharedAccountId('linkedin', config('platforms.linkedin.ad_account_id'));

        if (blank($accountId)) {
            throw new ProviderNotConfiguredException(
                'This campaign has no LinkedIn ad account, and none is configured. Choose one with '
                .'list_ad_accounts, or set LINKEDIN_AD_ACCOUNT_ID.'
            );
        }

        return (string) $accountId;
    }

    /**
     * Build the structured targetingCriteria object expected by the LinkedIn Ads API.
     *
     * LinkedIn requires a Record / DataMap with boolean include/and/or logic rather
     * than a bare array, with at least locations and interfaceLocales facets.
     *
     * @return array<string, mixed>
     */
    private function buildTargetingCriteria(Campaign $campaign): array
    {
        if (! empty($campaign->targeting['include'])) {
            return $campaign->targeting;
        }

        $countries = array_values(array_filter(
            (array) ($campaign->locations['countries'] ?? ['US']),
            is_string(...)
        ));

        try {
            $geoUrns = ($this->countries ?? app(CountryCatalog::class))->resolve('linkedin', $countries, $campaign->ad_account_id);
        } catch (\RuntimeException $exception) {
            throw new LinkedInException($exception->getMessage(), previous: $exception);
        }

        $and = [
            ['or' => ['urn:li:adTargetingFacet:locations' => array_values(array_unique($geoUrns))]],
            // Read from the countries rather than pinned to en_US. A German
            // campaign was reaching only people whose LinkedIn is in English,
            // which is a different and much smaller audience than the one the
            // buyer asked for.
            ['or' => ['urn:li:adTargetingFacet:interfaceLocales' => $this->localesFor($countries)]],
        ];

        // Devices were collected and dropped. LinkedIn does not model them as a
        // facet, so the answer is kept where the panel and the gate can read it
        // and is not silently presented as sent.
        if ($seniorities = $this->seniorityFacet($campaign)) {
            $and[] = ['or' => ['urn:li:adTargetingFacet:seniorities' => $seniorities]];
        }

        return ['include' => ['and' => $and]];
    }

    /**
     * Interface locales for the countries being targeted.
     *
     * Pinned to en_US for every campaign, so advertising in Germany reached
     * only people running LinkedIn in English. The locale is how LinkedIn
     * decides which language the member sees the site in, which is a fair proxy
     * for the language the ad should be written in.
     *
     * English is kept alongside, not replaced: professional LinkedIn use is
     * often in English even where the country is not, and dropping it would
     * shrink the audience in the other direction.
     *
     * @param  list<string>  $countries
     * @return list<string>
     */
    private function localesFor(array $countries): array
    {
        $byCountry = [
            'DE' => 'de_DE',
            'FR' => 'fr_FR',
            'ES' => 'es_ES',
            'IT' => 'it_IT',
            'NL' => 'nl_NL',
            'BR' => 'pt_BR',
            'MX' => 'es_ES',
            'JP' => 'ja_JP',
            'SE' => 'sv_SE',
        ];

        $locales = ['urn:li:locale:en_US'];

        foreach ($countries as $country) {
            if ($locale = $byCountry[strtoupper(trim($country))] ?? null) {
                $locales[] = 'urn:li:locale:'.$locale;
            }
        }

        return array_values(array_unique($locales));
    }

    /**
     * Seniority targeting, when the buyer named one.
     *
     * The audience the flow collects is Meta's shape: ages, genders, devices.
     * None of those are LinkedIn facets, which is why they were dropped rather
     * than mistranslated, and seniority is the one LinkedIn-native audience
     * field the product can set today.
     *
     * @return list<string>
     */
    private function seniorityFacet(Campaign $campaign): array
    {
        $seniorities = (array) ($campaign->audience['seniorities'] ?? []);

        return array_values(array_filter(array_map(
            fn (mixed $s): string => str_starts_with((string) $s, 'urn:')
                ? (string) $s
                : 'urn:li:seniority:'.$s,
            $seniorities,
        )));
    }

    /**
     * Map internal/generic campaign objectives to LinkedIn's valid objectiveType enum.
     */
    private function objectiveType(?string $objective): string
    {
        return match (strtoupper((string) $objective)) {
            'WEBSITE_VISITS', 'WEBSITE_VISIT' => 'WEBSITE_VISIT',
            'LEAD_GENERATION' => 'LEAD_GENERATION',
            'BRAND_AWARENESS' => 'BRAND_AWARENESS',
            'ENGAGEMENT' => 'ENGAGEMENT',
            'VIDEO_VIEWS', 'VIDEO_VIEW' => 'VIDEO_VIEW',
            'JOB_APPLY', 'JOB_APPLICANTS', 'JOB_APPLICATION' => 'JOB_APPLICANTS',
            'WEBSITE_CONVERSIONS', 'WEBSITE_CONVERSION' => 'WEBSITE_CONVERSIONS',
            // Refused rather than quietly turned into traffic.
            //
            // This defaulted to WEBSITE_VISIT, so an objective that did not map
            // became a traffic campaign without a word. A buyer who asked for
            // leads would have got clicks, been charged for clicks, and had no
            // way to tell from the chat or the panel, both of which would still
            // have said leads.
            default => throw new LinkedInException(sprintf(
                '[%s] is not an objective LinkedIn accepts. Supported: %s.',
                $objective ?: 'none',
                implode(', ', (new LinkedInRules)->objectives()),
            )),
        };
    }

    /**
     * Execute a callback with a local filesystem path to the asset, creating
     * a temporary copy if the asset is stored on a remote disk.
     *
     * @template T
     *
     * @param  \Closure(string): T  $callback
     * @return T|null
     */
    private function withLocalAssetPath(Asset $asset, \Closure $callback): mixed
    {
        $disk = Storage::disk($asset->disk ?: 'local');
        if (! $disk->exists($asset->path)) {
            return null;
        }

        $isTemp = false;
        $path = null;

        try {
            $path = $disk->path($asset->path);
            if (! file_exists($path)) {
                $path = null;
            }
        } catch (Throwable) {
            $path = null;
        }

        if ($path === null) {
            $path = tempnam(sys_get_temp_dir(), 'arb_li_');
            if ($path === false) {
                return null;
            }
            file_put_contents($path, $disk->get($asset->path));
            $isTemp = true;
        }

        try {
            return $callback($path);
        } finally {
            if ($isTemp && file_exists($path)) {
                @unlink($path);
            }
        }
    }

    /**
     * Upload an asset's image binary to LinkedIn and cache the resulting URN.
     */
    private function uploadAssetImage(LinkedIn $linkedIn, string $adAccountId, Asset $asset, ?string $ownerUrn = null): ?string
    {
        return $this->withLocalAssetPath($asset, function (string $path) use ($linkedIn, $adAccountId, $asset, $ownerUrn): string {
            $imageUrn = $linkedIn->uploadImage($adAccountId, $path, (string) $asset->original_name, $ownerUrn);

            AssetPlatformRef::updateOrCreate(
                [
                    'asset_id' => $asset->id,
                    'platform' => 'linkedin',
                    'ad_account_id' => $adAccountId,
                ],
                ['external_id' => $imageUrn],
            );

            return $imageUrn;
        });
    }
}
