<?php

declare(strict_types=1);

use App\Jobs\RecBuildItemPairsFromFavouritesJob;
use App\Models\Artwork;
use App\Models\ArtworkFavourite;
use App\Models\Category;
use App\Models\RecArtworkRec;
use App\Models\RecItemPair;
use App\Models\Tag;
use App\Models\User;
use App\Services\Recommendations\HybridSimilarArtworksService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;

uses(RefreshDatabase::class);

beforeEach(function () {
    config(['scout.driver' => 'null']);
    Cache::flush();
});

// ─── Helper ────────────────────────────────────────────────────────────────────

function createPublicArtwork(array $attrs = []): Artwork
{
    return Artwork::withoutEvents(function () use ($attrs) {
        return Artwork::factory()->create(array_merge([
            'is_public'    => true,
            'is_approved'  => true,
            'published_at' => now()->subHour(),
        ], $attrs));
    });
}

// ─── API returns fallback if precomputed list is missing ───────────────────────

it('returns fallback results when no precomputed similar list exists', function () {
    $artwork = createPublicArtwork();

    // Create some other artworks so the trending fallback can find them
    $other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]);
    $other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    // Should still return artworks via trending fallback, not an empty set
    expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class)
        ->and($result)->not->toBeEmpty();
});

it('returns empty collection for non-existent artwork', function () {
    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork(999999, 12);

    expect($result)->toBeEmpty();
});

it('returns similar_tags list when hybrid is missing', function () {
    $artwork = createPublicArtwork();
    $similar1 = createPublicArtwork();
    $similar2 = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_tags',
        'model_version' => 'sim_v1',
        'recs'          => [$similar1->id, $similar2->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    expect($result)->toHaveCount(2)
        ->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]);
});

// ─── Ordering is preserved ─────────────────────────────────────────────────────

it('preserves precomputed ordering exactly', function () {
    $artwork = createPublicArtwork();
    $a = createPublicArtwork();
    $b = createPublicArtwork();
    $c = createPublicArtwork();
    $d = createPublicArtwork();

    // Deliberate non-sequential order
    $orderedIds = [$c->id, $a->id, $d->id, $b->id];

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_hybrid',
        'model_version' => 'sim_v1',
        'recs'          => $orderedIds,
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    expect($result->pluck('id')->all())->toEqual($orderedIds);
});

it('falls through from hybrid to tags preserving order', function () {
    $artwork = createPublicArtwork();
    $a = createPublicArtwork();
    $b = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_tags',
        'model_version' => 'sim_v1',
        'recs'          => [$b->id, $a->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]);
});

// ─── Diversity cap (max per author) is enforced ────────────────────────────────

it('enforces author diversity cap at runtime', function () {
    $artwork = createPublicArtwork();

    // One author with 4 artworks
    $author = User::factory()->create();
    $sameAuthor1 = createPublicArtwork(['user_id' => $author->id]);
    $sameAuthor2 = createPublicArtwork(['user_id' => $author->id]);
    $sameAuthor3 = createPublicArtwork(['user_id' => $author->id]);
    $sameAuthor4 = createPublicArtwork(['user_id' => $author->id]);

    // Another author with 1 artwork
    $otherAuthor = User::factory()->create();
    $diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]);

    // Put all 5 in the precomputed list — same author dominates
    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_hybrid',
        'model_version' => 'sim_v1',
        'recs'          => [
            $sameAuthor1->id,
            $sameAuthor2->id,
            $sameAuthor3->id,
            $sameAuthor4->id,
            $diffAuthor->id,
        ],
        'computed_at' => now(),
    ]);

    config(['recommendations.similarity.max_per_author' => 2]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    // Max 2 from same author, 1 from different author = 3 total
    $resultByAuthor = $result->groupBy('user_id');
    foreach ($resultByAuthor as $authorId => $artworks) {
        expect($artworks->count())->toBeLessThanOrEqual(2);
    }
    expect($result)->toHaveCount(3);
});

// ─── Pair building doesn't explode per user ────────────────────────────────────

it('caps pairs per user to avoid combinatorial explosion', function () {
    $user = User::factory()->create();

    // Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue)
    $artworks = [];
    for ($i = 0; $i < 5; $i++) {
        $art = createPublicArtwork();
        $artworks[] = $art;
        DB::table('artwork_favourites')->insert([
            'user_id'    => $user->id,
            'artwork_id' => $art->id,
            'created_at' => now()->subMinutes($i),
            'updated_at' => now()->subMinutes($i),
        ]);
    }

    $job = new RecBuildItemPairsFromFavouritesJob();
    $pairs = $job->pairsForUser($user->id, 5);

    // C(5,2) = 10 pairs max
    expect($pairs)->toHaveCount(10);

    // Verify each pair is ordered (a < b)
    foreach ($pairs as [$a, $b]) {
        expect($a)->toBeLessThan($b);
    }
});

it('respects the favourites cap for pair generation', function () {
    $user = User::factory()->create();

    // Create 10 favourites (bypass observers)
    for ($i = 0; $i < 10; $i++) {
        $art = createPublicArtwork();
        DB::table('artwork_favourites')->insert([
            'user_id'    => $user->id,
            'artwork_id' => $art->id,
            'created_at' => now()->subMinutes($i),
            'updated_at' => now()->subMinutes($i),
        ]);
    }

    // Cap at 3 → C(3,2) = 3 pairs
    $job = new RecBuildItemPairsFromFavouritesJob();
    $pairs = $job->pairsForUser($user->id, 3);

    expect($pairs)->toHaveCount(3);
});

it('returns empty pairs for user with only one favourite', function () {
    $user = User::factory()->create();
    $art = createPublicArtwork();
    DB::table('artwork_favourites')->insert([
        'user_id'    => $user->id,
        'artwork_id' => $art->id,
        'created_at' => now(),
        'updated_at' => now(),
    ]);

    $job = new RecBuildItemPairsFromFavouritesJob();
    $pairs = $job->pairsForUser($user->id, 50);

    expect($pairs)->toBeEmpty();
});

// ─── API endpoint integration ──────────────────────────────────────────────────

it('returns JSON response from API endpoint with precomputed data', function () {
    $artwork = createPublicArtwork();
    $similar = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_hybrid',
        'model_version' => 'sim_v1',
        'recs'          => [$similar->id],
        'computed_at'   => now(),
    ]);

    $response = $this->getJson("/api/art/{$artwork->id}/similar");

    $response->assertOk()
        ->assertJsonStructure(['data'])
        ->assertJsonCount(1, 'data');
});

it('returns 404 for non-existent artwork in API', function () {
    $response = $this->getJson('/api/art/999999/similar');

    $response->assertNotFound();
});

// ─── RecArtworkRec model ───────────────────────────────────────────────────────

it('stores and retrieves rec list with correct types', function () {
    $artwork = createPublicArtwork();
    $ids = [10, 20, 30];

    $rec = RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_hybrid',
        'model_version' => 'sim_v1',
        'recs'          => $ids,
        'computed_at'   => now(),
    ]);

    $fresh = RecArtworkRec::find($rec->id);
    expect($fresh->recs)->toBeArray()
        ->and($fresh->recs)->toEqual($ids)
        ->and($fresh->artwork_id)->toBe($artwork->id);
});

// ─── Fallback priority ─────────────────────────────────────────────────────────

it('chooses similar_behavior when tags and hybrid are missing', function () {
    $artwork = createPublicArtwork();
    $beh1 = createPublicArtwork();
    $beh2 = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_behavior',
        'model_version' => 'sim_v1',
        'recs'          => [$beh1->id, $beh2->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]);
});

it('filters out unpublished artworks from precomputed list', function () {
    $artwork = createPublicArtwork();
    $published = createPublicArtwork();
    $unpublished = Artwork::withoutEvents(function () {
        return Artwork::factory()->unpublished()->create();
    });

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_hybrid',
        'model_version' => 'sim_v1',
        'recs'          => [$unpublished->id, $published->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12);

    expect($result->pluck('id')->all())->toEqual([$published->id]);
});

// ─── Type query param support (spec §8) ────────────────────────────────────────

it('returns specific rec type when ?type=tags is passed', function () {
    $artwork = createPublicArtwork();
    $tagSimilar = createPublicArtwork();
    $behSimilar = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_tags',
        'model_version' => 'sim_v1',
        'recs'          => [$tagSimilar->id],
        'computed_at'   => now(),
    ]);
    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_behavior',
        'model_version' => 'sim_v1',
        'recs'          => [$behSimilar->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12, 'tags');

    expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]);
});

it('returns behavior list when ?type=behavior is passed', function () {
    $artwork = createPublicArtwork();
    $behSimilar = createPublicArtwork();
    $tagSimilar = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_tags',
        'model_version' => 'sim_v1',
        'recs'          => [$tagSimilar->id],
        'computed_at'   => now(),
    ]);
    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_behavior',
        'model_version' => 'sim_v1',
        'recs'          => [$behSimilar->id],
        'computed_at'   => now(),
    ]);

    $service = app(HybridSimilarArtworksService::class);
    $result  = $service->forArtwork($artwork->id, 12, 'behavior');

    expect($result->pluck('id')->all())->toEqual([$behSimilar->id]);
});

it('passes type query param from API endpoint', function () {
    $artwork = createPublicArtwork();
    $tagSimilar = createPublicArtwork();

    RecArtworkRec::create([
        'artwork_id'    => $artwork->id,
        'rec_type'      => 'similar_tags',
        'model_version' => 'sim_v1',
        'recs'          => [$tagSimilar->id],
        'computed_at'   => now(),
    ]);

    $response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags");

    $response->assertOk()
        ->assertJsonCount(1, 'data');
});

// ─── Cosine normalized pair weights ────────────────────────────────────────────

it('produces cosine-normalized weights in pair builder', function () {
    // User A: likes artwork 1, 2
    $userA = User::factory()->create();
    $art1 = createPublicArtwork();
    $art2 = createPublicArtwork();
    DB::table('artwork_favourites')->insert([
        ['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
        ['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
    ]);

    // User B: also likes artwork 1, 2
    $userB = User::factory()->create();
    DB::table('artwork_favourites')->insert([
        ['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
        ['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
    ]);

    $job = new RecBuildItemPairsFromFavouritesJob();
    $job->handle();

    $pair = RecItemPair::query()
        ->where('a_artwork_id', min($art1->id, $art2->id))
        ->where('b_artwork_id', max($art1->id, $art2->id))
        ->first();

    expect($pair)->not->toBeNull();
    // co_like = 2 (both users liked both), likes_A = 2, likes_B = 2
    // S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
    expect($pair->weight)->toBe(1.0);
});
