<?php

declare(strict_types=1);

namespace Tests\Feature\Ranking;

use App\Models\Artwork;
use App\Models\RankList;
use App\Models\User;
use App\Services\RankingService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;

/**
 * Tests for the ranking system API endpoints and service logic.
 *
 * Covered:
 *  1. GET /api/rank/global returns artworks in the pre-ranked order.
 *  2. RankingService::applyDiversity() enforces max-per-author cap.
 *  3. Fallback to latest when no rank_list row exists for a scope.
 */
class RankGlobalTrendingTest extends TestCase
{
    use RefreshDatabase;

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

        Cache::flush();
    }

    // ── Test 1: ranked order ───────────────────────────────────────────────

    /**
     * A stored rank_list drives the response order.
     * The controller must return artworks in the same sequence as artwork_ids.
     */
    public function test_global_trending_returns_artworks_in_ranked_order(): void
    {
        $user = User::factory()->create();

        // Create three artworks in chronological order (oldest first)
        $oldest = Artwork::factory()->for($user)->create([
            'published_at' => now()->subDays(3),
        ]);
        $middle = Artwork::factory()->for($user)->create([
            'published_at' => now()->subDays(2),
        ]);
        $newest = Artwork::factory()->for($user)->create([
            'published_at' => now()->subDays(1),
        ]);

        // The rank list ranks them in a non-chronological order to prove it is respected:
        // newest > oldest > middle
        $rankedOrder = [$newest->id, $oldest->id, $middle->id];

        RankList::create([
            'scope_type'    => 'global',
            'scope_id'      => 0,
            'list_type'     => 'trending',
            'model_version' => (string) config('ranking.model_version', 'rank_v1'),
            'artwork_ids'   => $rankedOrder,
            'computed_at'   => now(),
        ]);

        $response = $this->getJson('/api/rank/global?type=trending');

        $response->assertOk();

        $returnedIds = collect($response->json('data'))
            ->pluck('slug')
            ->all();

        // Retrieve slugs in the expected order for comparison
        $expectedSlugs = Artwork::whereIn('id', $rankedOrder)
            ->get()
            ->keyBy('id')
            ->pipe(fn ($keyed) => array_map(fn ($id) => $keyed[$id]->slug, $rankedOrder));

        $this->assertSame($expectedSlugs, $returnedIds,
            'Artworks must be returned in the exact pre-ranked order.'
        );

        // Meta block is present
        $response->assertJsonPath('meta.list_type', 'trending');
        $response->assertJsonPath('meta.fallback', false);
        $response->assertJsonPath('meta.model_version', (string) config('ranking.model_version', 'rank_v1'));
    }

    // ── Test 2: diversity constraint ───────────────────────────────────────

    /**
     * applyDiversity() must cap the number of artworks per author
     * at config('ranking.diversity.max_per_author') = 3.
     *
     * Given 5 artworks from the same author, only 3 should pass through.
     */
    public function test_diversity_constraint_caps_items_per_author(): void
    {
        /** @var RankingService $service */
        $service = app(RankingService::class);

        $maxPerAuthor = (int) config('ranking.diversity.max_per_author', 3);
        $listSize     = 50;

        // Build fake candidates: 5 from author 1, 3 from author 2
        $candidates = [];
        for ($i = 1; $i <= 5; $i++) {
            $candidates[] = (object) ['artwork_id' => $i, 'user_id' => 1];
        }
        for ($i = 6; $i <= 8; $i++) {
            $candidates[] = (object) ['artwork_id' => $i, 'user_id' => 2];
        }

        $result = $service->applyDiversity($candidates, $maxPerAuthor, $listSize);

        $authorTotals = array_count_values(
            array_map(fn ($item) => (int) $item->user_id, $result)
        );

        foreach ($authorTotals as $authorId => $count) {
            $this->assertLessThanOrEqual(
                $maxPerAuthor,
                $count,
                "Author {$authorId} appears {$count} times, but max is {$maxPerAuthor}."
            );
        }

        // Exactly 3 from author 1 + 3 from author 2 = 6 total
        $this->assertCount(6, $result);
    }

    // ── Test 3: fallback to latest ─────────────────────────────────────────

    /**
     * When no rank_list row exists for the requested scope, the controller
     * falls back to latest-published artworks and signals this in the meta.
     */
    public function test_global_trending_falls_back_to_latest_when_no_rank_list_exists(): void
    {
        $user = User::factory()->create();

        // Create artworks in a known order so we can verify fallback ordering
        $first  = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(3)]);
        $second = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(2)]);
        $third  = Artwork::factory()->for($user)->create(['published_at' => now()->subHours(1)]);

        // Deliberately leave rank_lists table empty

        $response = $this->getJson('/api/rank/global?type=trending');

        $response->assertOk();

        // Meta must indicate fallback
        $response->assertJsonPath('meta.fallback', true);
        $response->assertJsonPath('meta.model_version', 'fallback');

        // Artworks must appear in published_at DESC order (third, second, first)
        $returnedIds = collect($response->json('data'))->pluck('slug')->all();

        $this->assertSame(
            [$third->slug, $second->slug, $first->slug],
            $returnedIds,
            'Fallback must return artworks in latest-first order.'
        );
    }
}
