<?php

namespace App\Services\Platforms\Support;

use App\Models\ApiCall;
use App\Services\Meta\ObjectId;

/**
 * Wraps every call to an ad platform: guardrails before, audit row after,
 * whatever the outcome. Every transport goes through this, so api_calls is a
 * complete record regardless of which one ran.
 *
 * Lived in App\Services\Meta while Meta was the only platform, which made it
 * look like Meta's own bookkeeping rather than the one place every call is
 * counted, allow-listed and recorded. Google Ads goes through it too, and a
 * platform that does not is a platform outside the call budget and outside the
 * audit trail.
 */
class CallLogger
{
    /** Attempts at one call, when the platform only refused to take it. */
    private const ATTEMPTS = 3;

    /** How much of a response is kept. Bytes, because that is what a column holds. */
    private const MAX_RESPONSE_BYTES = 4000;

    /**
     * How a platform says "not now" rather than "no".
     *
     * These are refusals, not failures: the request was never processed, which
     * is what makes repeating one safe even for a write. Matched on the message
     * because every transport flattens the error into it, codes included.
     *
     * Meta: 80004 is the ads-management hourly limit, 613 the calls-per-hour
     * limit, 17 the per-user limit and 4 the per-app one. All four blocked real
     * work during testing.
     *
     * Google: quota refusals arrive as RESOURCE_EXHAUSTED, and the Ads API
     * reports its own limits as RATE_EXCEEDED with a retry delay.
     */
    private const THROTTLED = [
        'too many calls',
        'rate limit',
        'request limit reached',
        'reduce the amount of data',
        '80004',
        '613',
        ' 17/',
        ' 4/',
        '429',
        '503',
        'resource_exhausted',
        'rate_exceeded',
        'quota',
    ];

    private ?int $campaignId = null;

    private ?string $actor = null;

    public function __construct(
        private readonly Guardrails $guardrails,
    ) {}

    public function attributeTo(?int $campaignId, ?string $actor): static
    {
        $this->campaignId = $campaignId;
        $this->actor = $actor;

        return $this;
    }

    /**
     * Reads already made in this request, keyed by what was asked.
     *
     * Request scoped by construction: CallLogger is resolved per request, so
     * nothing survives the turn. Deliberately not a cache with a lifetime,
     * which would mean deciding how stale a Page list may be; this only removes
     * a question asked twice in the same breath.
     *
     * @var array<string, array<string,mixed>>
     */
    private array $reads = [];

    private function memoKey(CallContext $context): string
    {
        return implode('|', [
            $context->platform,
            $context->transport,
            $context->method,
            $context->endpoint,
            md5(json_encode($context->request) ?: ''),
        ]);
    }

    /**
     * @param  \Closure(): array<string,mixed>  $call
     * @return array<string,mixed>
     *
     * @throws MetaException
     */
    public function record(CallContext $context, \Closure $call): array
    {
        // The same read, asked for again inside one request.
        //
        // Measured on dev: 86 of 306 non-mutating calls were exact repeats
        // within ten seconds, costing 34.8 seconds of somebody's turn. One ad
        // account was read seven times while building a single campaign,
        // because every tool that needs its name or currency fetches it, and
        // none of them knew another already had.
        //
        // Reads only, and only for the life of this request. An ad account read
        // twice a second apart cannot legitimately have changed, so this is
        // removing a duplicate rather than caching a value. Nothing is held
        // between turns: a Page created between two messages still appears.
        //
        // No audit row for a repeat, because no call was made. The trail
        // records what reached the platform.
        $memo = $context->mutating ? null : $this->memoKey($context);

        if ($memo !== null && array_key_exists($memo, $this->reads)) {
            return $this->reads[$memo];
        }

        if ($context->guarded && $context->mutating) {
            $this->guardrails->assertWritesAllowed();
        }

        // Asked separately from the kill switch. A delete is exempt from the
        // pause so a rollback can finish; it is not exempt from being aimed at
        // an account we are allowed to touch.
        if ($context->accountChecked && $context->adAccountId) {
            $this->guardrails->assertAccountAllowed($context->adAccountId, $context->platform);
        }

        $attempt = 0;

        while (true) {
            $attempt++;

            // Re-checked every attempt, not once. Each attempt is a real call
            // against Meta's quota and writes its own api_calls row, so a
            // throttled burst could take the budget two calls past its limit
            // while the check that exists to prevent exactly that had already
            // run and passed.
            if ($context->guarded) {
                $this->guardrails->assertWithinCallBudget($context->adAccountId);
            }

            try {
                $result = $this->attempt($context, $call);

                if ($memo !== null) {
                    $this->reads[$memo] = $result;
                }

                return $result;
            } catch (\Throwable $e) {
                if (! $this->worthRetrying($e, $context, $attempt)) {
                    throw $e;
                }

                // Meta's limits are per hour, so a few hundred milliseconds
                // will not clear one. Long enough to ride out a burst, short
                // enough that somebody watching a spinner does not give up.
                // Configurable so the test suite does not sit through it.
                $step = (int) config('platforms.guardrails.retry_backoff_ms', 500);

                if ($step > 0) {
                    usleep(min($attempt * $attempt, 16) * $step * 1000);
                }
            }
        }
    }

    /**
     * Whether this failure is worth trying again.
     *
     * A throttle is an explicit refusal: Meta says it did not process the
     * request, so repeating it cannot create a second campaign. A timeout says
     * nothing of the sort. The reply may have been lost after the work was
     * done, so a write that times out is never repeated, which is the same
     * reasoning as the double publish guard.
     */
    private function worthRetrying(\Throwable $e, CallContext $context, int $attempt): bool
    {
        if ($attempt >= self::ATTEMPTS) {
            return false;
        }

        $message = strtolower($e->getMessage());

        foreach (self::THROTTLED as $sign) {
            if (str_contains($message, $sign)) {
                return true;
            }
        }

        // Everything else is only safe to repeat when it changed nothing.
        return ! $context->mutating
            && (str_contains($message, 'timed out') || str_contains($message, 'timeout'));
    }

    /** One attempt, timed and recorded whatever the outcome. */
    private function attempt(CallContext $context, \Closure $call): array
    {
        $startedAt = microtime(true);
        $response = null;
        $error = null;

        try {
            return $response = $call();
        } catch (\Throwable $e) {
            $error = $e->getMessage();

            throw $e;
        } finally {
            ApiCall::create([
                'campaign_id' => $this->campaignId,
                'actor' => $this->actor,
                // Never written, so every row took the column default and all
                // 390 of them said meta, Google's included. The guardrails were
                // unaffected because they are handed $context->platform
                // directly; it was the audit trail that was wrong, which is the
                // one thing an audit trail cannot be.
                'platform' => $context->platform,
                'transport' => $context->transport,
                'method' => $context->method,
                'endpoint' => $context->endpoint,
                'ad_account_id' => $context->adAccountId,
                'mutating' => $context->mutating,
                'dry_run' => $context->dryRun,
                'request' => $this->guardrails->scrub($context->request),
                'response' => $this->truncate($response),
                'status' => $error === null ? 200 : null,
                'ok' => $error === null,
                'object_id' => $response ? ObjectId::find($response) : null,
                'error' => $error ? mb_substr($error, 0, 900) : null,
                'duration_ms' => (int) round((microtime(true) - $startedAt) * 1000),
            ]);
        }
    }

    /**
     * Keep enough of the response to diagnose a shape change, not all of it.
     *
     * This cut the encoded JSON at 4000 characters and decoded the offcut. JSON
     * cut mid-structure is not JSON, so json_decode returned null and the column
     * was written null - for every response over the limit and no other. The
     * small responses were kept in full and the large ones were thrown away
     * entirely, which is the opposite of what a log is for: 25 successful calls
     * in the local log have no response recorded, and they are the account lists
     * and campaign lists, exactly the ones whose shape anyone would want to see.
     *
     * So the data is reduced rather than the text cut: long strings shortened,
     * long lists capped, both marked, each pass tighter than the last until it
     * fits. What comes out is valid, readable, and still shaped like the response.
     */
    private function truncate(?array $response): ?array
    {
        if ($response === null) {
            return null;
        }

        if ($this->fits($response)) {
            return $this->reduce($response, PHP_INT_MAX, PHP_INT_MAX);
        }

        foreach ([[20, 500], [5, 200], [2, 80], [1, 40]] as [$items, $chars]) {
            $reduced = $this->reduce($response, $items, $chars);

            if ($this->fits($reduced)) {
                return $reduced;
            }
        }

        // A response that will not fit even one shortened item of each list still
        // has a shape, and the shape is the thing being diagnosed.
        return ['_truncated' => 'only the keys fit', 'keys' => array_keys($response)];
    }

    private function fits(array $value): bool
    {
        return strlen((string) json_encode($value)) <= self::MAX_RESPONSE_BYTES;
    }

    /**
     * The same data, smaller, and still the same shape.
     *
     * access_token is dropped at every depth on the way through. Guardrails::scrub
     * does that for the request but only at the top level, and a response can
     * carry one nested - me/accounts returns a page token per page when the field
     * is asked for. A log is a bad place to learn that.
     */
    private function reduce(mixed $value, int $items, int $chars): mixed
    {
        if (is_string($value)) {
            return mb_strlen($value) <= $chars
                ? $value
                : mb_substr($value, 0, $chars).sprintf('… [%d chars]', mb_strlen($value));
        }

        if (! is_array($value)) {
            return $value;
        }

        $isList = array_is_list($value);
        $reduced = [];

        foreach ($isList ? array_slice($value, 0, $items) : $value as $key => $item) {
            if (is_string($key) && strcasecmp($key, 'access_token') === 0) {
                continue;
            }

            $reduced[$key] = $this->reduce($item, $items, $chars);
        }

        if ($isList && count($value) > $items) {
            $reduced[] = sprintf('… [%d more]', count($value) - $items);
        }

        return $reduced;
    }
}
