<?php

namespace Tests\Feature;

use App\Models\ApiCall;
use App\Models\User;
use App\Services\Platforms\Support\CallContext;
use App\Services\Platforms\Support\CallLogger;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

/**
 * The call log keeps the responses worth looking at.
 *
 * It 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 for no other. Small
 * responses were kept whole; large ones were discarded entirely.
 *
 * That is backwards. The large ones are the account lists, the campaign lists and
 * the insights payloads, which is where a shape change hides. 25 successful calls
 * in the local log have no response recorded at all.
 *
 * So the data is reduced instead of the text being cut, and what is stored stays
 * valid JSON shaped like the original.
 */
class TheLogKeepsTheBigResponsesTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        config([
            'platforms.guardrails.allowed_ad_accounts' => ['meta:*'],
            'platforms.guardrails.max_calls_per_hour' => 1000,
        ]);

        $this->actingAs(User::factory()->create());
    }

    /** Log a real call through the logger, and hand back the row it wrote. */
    private function logged(array $response): ApiCall
    {
        app(CallLogger::class)->record(
            new CallContext(
                platform: 'meta',
                transport: 'graph',
                method: 'GET',
                endpoint: 'act_123/campaigns',
                adAccountId: 'act_123',
                mutating: false,
            ),
            fn (): array => $response,
        );

        return ApiCall::latest('id')->firstOrFail();
    }

    /** A list of sixty campaigns, which is roughly 6.5kB of JSON. */
    private function sixtyCampaigns(): array
    {
        return ['data' => array_map(fn (int $i): array => [
            'id' => '1200000000'.$i,
            'name' => 'Campaign number '.$i.' with a reasonably long descriptive name',
            'status' => 'ACTIVE',
        ], range(1, 60))];
    }

    // -------------------------------------------------------------- the defect

    /** The one that used to be thrown away. */
    public function test_a_large_response_is_recorded_rather_than_nulled(): void
    {
        $row = $this->logged($this->sixtyCampaigns());

        $this->assertNotNull($row->response, 'a response over the limit was discarded entirely');
        $this->assertIsArray($row->response);
    }

    /** And what is kept is the shape, not a fragment. */
    public function test_the_stored_response_keeps_the_shape(): void
    {
        $response = $this->logged($this->sixtyCampaigns())->response;

        $this->assertArrayHasKey('data', $response);
        $this->assertSame('1200000000'.'1', $response['data'][0]['id']);
        $this->assertSame('ACTIVE', $response['data'][0]['status']);
    }

    /** The list is capped, and says how much it dropped. */
    public function test_a_capped_list_says_what_it_dropped(): void
    {
        $response = $this->logged($this->sixtyCampaigns())['response'];

        $this->assertLessThan(60, count($response['data']));
        $this->assertStringContainsString('more', (string) end($response['data']));
    }

    /** It still fits, which is the whole reason any of this happens. */
    public function test_the_stored_response_fits_the_budget(): void
    {
        $this->assertLessThanOrEqual(
            4000,
            strlen((string) json_encode($this->logged($this->sixtyCampaigns())->response)),
        );
    }

    // ------------------------------------------------------ the ordinary cases

    /** A small response is stored exactly as it arrived. */
    public function test_a_small_response_is_untouched(): void
    {
        $response = ['id' => '120252047635070626', 'name' => 'Lucos leads', 'status' => 'PAUSED'];

        $this->assertSame($response, $this->logged($response)->response);
    }

    /** A single enormous string is shortened, not dropped. */
    public function test_one_very_long_string_is_shortened(): void
    {
        $row = $this->logged(['message' => str_repeat('x', 9000)]);

        $this->assertNotNull($row->response);
        $this->assertStringContainsString('9000 chars', $row->response['message']);
        $this->assertLessThanOrEqual(4000, strlen((string) json_encode($row->response)));
    }

    /**
     * A nested access token does not reach the log.
     *
     * Guardrails::scrub drops one from the request, but only at the top level,
     * and a response can carry one deeper: me/accounts returns a page token per
     * page when the field is asked for.
     */
    public function test_a_nested_access_token_is_not_logged(): void
    {
        $row = $this->logged(['data' => [
            ['id' => '1', 'access_token' => 'EAABsecret', 'category' => 'Retail'],
        ]]);

        $encoded = (string) json_encode($row->response);

        $this->assertStringNotContainsString('EAABsecret', $encoded);
        $this->assertStringNotContainsString('access_token', $encoded);
        $this->assertStringContainsString('Retail', $encoded, 'the rest of the response was lost with it');
    }

    /** An empty response is still an empty response, not a null one. */
    public function test_an_empty_response_is_stored_as_empty(): void
    {
        $this->assertSame([], $this->logged([])->response);
    }
}
