<?php

namespace App\Services\Platforms\Support;

use App\Models\AllowedAdAccount;
use App\Models\ApiCall;
use App\Models\CampaignChange;
use App\Services\Meta\MetaException;
use Illuminate\Support\Facades\Log;

/**
 * Limits that hold regardless of what the model decides or the user types.
 *
 * Checked inside the API client, below the agent and below the controller, so
 * there is no path around them.
 */
class Guardrails
{
    /**
     * Refuse any ad account that is not explicitly allowed, before a single
     * call is made. Reads included: an account we may not spend on is an
     * account we have no business reading either.
     *
     * @throws MetaException
     */
    public function assertAccountAllowed(string $adAccountId, string $platform = 'meta'): void
    {
        $platform = strtolower(trim($platform));

        if (! $this->hasAnyAllowed($platform)) {
            throw new MetaException(
                'No ad accounts are allowed. Add one to the allowed_ad_accounts table, or set '
                .'PLATFORM_ALLOWED_AD_ACCOUNTS, before ARB can reach an ad platform.'
            );
        }

        // Compared platform by platform, not string against string.
        //
        // This briefly accepted a match on any of three spellings of the id,
        // one of which stripped the act_ prefix. Account ids are not
        // namespaced, so that let one platform's entry authorise another's
        // account: with a Google customer id on the list, the Meta account
        // with the same digits was permitted, and setup.sh puts a bare id on
        // the list, so dev was in exactly that state. Both sides are now
        // reduced to this platform's own form before they are compared.
        if (! $this->allows($adAccountId, $platform)) {
            $permitted = $this->permittedAccounts($platform);
            $fallback = (array) config('platforms.guardrails.allowed_ad_accounts');

            // The message names both sources, because permission is the union
            // of them and revoking an account means removing it from each.
            // Deactivating the row alone leaves a config entry standing, which
            // is the opposite of what someone deactivating a row intends.
            throw new MetaException(sprintf(
                '%s is not on the allowed list. Permitted: %s. The list is the allowed_ad_accounts '
                .'table and PLATFORM_ALLOWED_AD_ACCOUNTS together, so an account has to be absent '
                .'from both to be refused.',
                $adAccountId,
                implode(', ', $permitted ?: $fallback),
            ));
        }
    }

    /**
     * The same question as assertAccountAllowed(), asked without the throw.
     *
     * For callers that want to know before they commit to an account rather
     * than after. A Google campaign on arb-dev was built end to end against a
     * customer id that was never on the list, because Google's account is
     * adopted from configuration and only checked at publish: every question
     * answered, three ads written, then refused. Asking here turns that into a
     * sentence at the first step.
     *
     * Fails closed on an empty list, the same as the assertion does.
     */
    public function allows(string $adAccountId, string $platform = 'meta'): bool
    {
        $platform = strtolower(trim($platform));
        $permitted = $this->permittedAccounts($platform);

        if ($permitted === []) {
            return false;
        }

        // "meta:*" allows every Meta account and says nothing about the others,
        // so each platform is opened on its own. Written for dev, where the
        // accounts change often enough that enumerating them is the reason the
        // guard gets switched off entirely.
        if (in_array(AllowedAdAccount::WILDCARD, $permitted, true)) {
            return true;
        }

        return in_array($this->normalise($adAccountId, $platform), $permitted, true);
    }

    /**
     * Every allowed account ID for a platform, normalized.
     *
     * Checks database entries first. Falls back to config for environments
     * and tests that have not seeded allowed_ad_accounts or override config.
     *
     * @return list<string>
     */
    public function permittedAccounts(string $platform = 'meta'): array
    {
        $platform = strtolower(trim($platform));

        // The two lists are merged, not ranked.
        //
        // This returned the database list whenever it held anything, so one row
        // made PLATFORM_ALLOWED_AD_ACCOUNTS inert while the refusal at the top
        // of this class still told the buyer to go and set it. And it fell back
        // to config the moment the table was empty, so deactivating every row
        // to lock ARB out silently handed the config list straight back.
        //
        // Neither list is more authoritative than the other: both are an
        // administrator saying "this account is permitted", so the permitted
        // set is the union of the two. Revoking means removing it from both,
        // which is the only rule that cannot be satisfied by accident.
        $dbAllowed = [];

        try {
            $dbAllowed = AllowedAdAccount::allowedIds($platform);
        } catch (\Throwable $e) {
            // In migrations or before table exists, gracefully fallback
            Log::warning("Failed to query allowed_ad_accounts for platform '{$platform}', falling back to config: {$e->getMessage()}", [
                'platform' => $platform,
                'exception' => $e,
            ]);
        }

        $allowed = (array) config('platforms.guardrails.allowed_ad_accounts');

        $configAllowed = array_filter(array_map(
            fn (string $entry): ?string => $this->entryFor($entry, $platform),
            $allowed,
        ), fn (?string $entry): bool => $entry !== null);

        return array_values(array_unique([...$dbAllowed, ...$configAllowed]));
    }

    public function hasAnyAllowed(string $platform = 'meta'): bool
    {
        return $this->permittedAccounts($platform) !== [];
    }

    /**
     * An allow-list entry as this platform's id, or null when it is plainly
     * another platform's.
     *
     * One flat variable holds every platform's accounts, so an entry has to
     * say which platform it belongs to by its own shape. Meta writes
     * act_<digits> and LinkedIn writes a sponsoredAccount URN, both
     * unmistakable. A bare id is Google's shape and also LinkedIn's, so it
     * stands for either, but never for Meta, which always carries the prefix.
     * That is the distinction the widening lost.
     *
     * The remaining ambiguity is Google against LinkedIn on a bare id. Closing
     * it needs the list to name the platform, which is a config change rather
     * than a guard change.
     */
    private function entryFor(string $entry, string $platform): ?string
    {
        $trimmed = trim($entry);

        // A URN is recognised whatever case it is written in.
        //
        // Both checks here were case-sensitive, so `URN:LI:sponsoredAccount:5087`
        // failed the URN test, fell into the prefixed-entry branch below, split at
        // the first colon into a prefix of `urn`, matched no platform, and was
        // dropped from the allow list without a word. The administrator had
        // permitted that LinkedIn account and the guard refused it.
        $isUrn = str_starts_with(strtolower($trimmed), 'urn:li:');

        // Support platform prefix notation, e.g. "google:3460855874", "linkedin:556447014", "meta:act_12345"
        if (str_contains($trimmed, ':') && ! $isUrn) {
            [$prefix, $bare] = explode(':', $trimmed, 2);
            $prefix = strtolower(trim($prefix));
            $bare = trim($bare);

            if ($prefix === $platform) {
                return $this->normalise($bare, $platform);
            }

            return null;
        }

        // A star with no platform in front of it is refused rather than
        // guessed at. The rule below reads an unprefixed entry as Google or
        // LinkedIn and never Meta, so a bare "*" would open two platforms and
        // leave the third closed, which is nobody's intent.
        if ($trimmed === AllowedAdAccount::WILDCARD) {
            return null;
        }

        $belongsTo = match (true) {
            str_starts_with(strtolower($trimmed), 'act_') => 'meta',
            $isUrn => 'linkedin',
            default => null,
        };

        if ($belongsTo !== null) {
            return $belongsTo === $platform ? $this->normalise($trimmed, $platform) : null;
        }

        return $platform === 'meta' ? null : $this->normalise($trimmed, $platform);
    }

    /**
     * Stay inside a share of Meta's hourly limit, so anything else using the
     * same token keeps its own headroom.
     *
     * @throws MetaException
     */
    public function assertWithinCallBudget(?string $adAccountId): void
    {
        $limit = (int) config('platforms.guardrails.max_calls_per_hour');

        // Calls with no account of their own still spend the token's quota.
        // Listing Pages and ad accounts are both account-less, and returning
        // early here left them outside the budget entirely: the two edges that
        // sweep everything the token can see were the two nothing bounded.
        $used = ApiCall::query()
            ->when(
                $adAccountId,
                fn ($query) => $query->where('ad_account_id', $adAccountId),
                fn ($query) => $query->whereNull('ad_account_id'),
            )
            ->where('created_at', '>=', now()->subHour())
            ->count();

        if ($used >= $limit) {
            throw new MetaException(sprintf(
                'ARB has used its hourly call budget for %s (%d of %d). Pausing so other systems keep their share.',
                $adAccountId ?: 'calls that name no ad account',
                $used,
                $limit,
            ));
        }
    }

    /**
     * Refuse everything that writes to a platform.
     *
     * A switch rather than a deploy, because the moment you need it you cannot
     * wait for one. Reads keep working, so the product can still explain what
     * it did before it was stopped.
     *
     * @throws MetaException
     */
    public function assertWritesAllowed(): void
    {
        if (config('platforms.guardrails.paused')) {
            throw new MetaException(
                'All changes to advertising platforms are currently paused. Nothing was sent.'
            );
        }
    }

    /**
     * Hold a live campaign to a change rate.
     *
     * Two separate risks. The cooldown stops a retry loop, or a user and the
     * agent disagreeing, from rewriting the same campaign repeatedly within
     * seconds. The daily limit bounds the damage of anything that gets past it.
     *
     * @throws MetaException
     */
    public function assertChangeAllowed(int $campaignId): void
    {
        $cooldown = (int) config('platforms.guardrails.change_cooldown_seconds');
        $dailyLimit = (int) config('platforms.guardrails.max_changes_per_day');

        $recent = CampaignChange::query()
            ->where('campaign_id', $campaignId)
            ->where('state', 'applied')
            ->latest('applied_at');

        if ($cooldown > 0 && ($last = (clone $recent)->first())
            && $last->applied_at?->gt(now()->subSeconds($cooldown))) {
            throw new MetaException(sprintf(
                'This campaign was changed %d seconds ago. Changes are limited to one every %d seconds.',
                now()->diffInSeconds($last->applied_at, absolute: true),
                $cooldown,
            ));
        }

        $today = CampaignChange::query()
            ->where('campaign_id', $campaignId)
            ->where('state', 'applied')
            ->where('applied_at', '>=', now()->startOfDay())
            ->count();

        if ($today >= $dailyLimit) {
            throw new MetaException(sprintf(
                'This campaign has already been changed %d times today, which is the limit.',
                $today,
            ));
        }
    }

    /** Remove credentials and truncate anything oversized before logging. */
    public function scrub(array $payload): array
    {
        unset($payload['access_token']);

        return array_map(
            fn (mixed $value): mixed => is_string($value) && strlen($value) > 500
                ? substr($value, 0, 500).sprintf('… [%d chars]', strlen($value))
                : $value,
            $payload,
        );
    }

    /**
     * One id, written the way the allow list is written.
     * Delegates to AllowedAdAccount::normalizeAccountId() to guarantee consistency.
     */
    private function normalise(string $adAccountId, string $platform = 'meta'): string
    {
        return AllowedAdAccount::normalizeAccountId($adAccountId, $platform);
    }
}
