<?php

declare(strict_types=1);

namespace Tests\Feature\Marketplace;

use App\Enums\ListingStatus;
use App\Models\Domain;
use App\Models\DomainCategory;
use App\Models\Listing;
use App\Models\Tld;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

/**
 * The public domain search.
 *
 * Two properties matter more than any filter here. Nothing unverified or
 * unpublished may reach a buyer, and nothing private -- the seller's offer
 * floor, an enforcement action -- may leak through the resource. The filter
 * tests are the feature; those two are the guarantees.
 */
final class DomainSearchTest extends TestCase
{
    use RefreshDatabase;

    /** Creates an active, published listing on the given name. */
    private function listing(string $name, array $attributes = []): Listing
    {
        $domain = Domain::factory()->verified()->named($name)->create();

        return Listing::factory()
            ->forDomain($domain)
            ->active()
            ->create($attributes);
    }

    #[Test]
    public function domains_are_searchable_without_signing_in(): void
    {
        $this->listing('coffee.com');

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('data.0.domain.name', 'coffee.com')
            ->assertJsonPath('meta.total', 1);
    }

    /**
     * The central guarantee. Each of these rows is one a buyer must not see,
     * and each fails for a different reason.
     */
    #[Test]
    public function only_published_and_visible_listings_are_returned(): void
    {
        $this->listing('visible.com');

        Listing::factory()->forDomain(Domain::factory()->verified()->named('draft.com')->create())->create();
        Listing::factory()->forDomain(Domain::factory()->verified()->named('sold.com')->create())->sold()->create();
        Listing::factory()->forDomain(Domain::factory()->verified()->named('gone.com')->create())->withdrawn()->create();
        Listing::factory()->forDomain(Domain::factory()->verified()->named('stopped.com')->create())->suspended()->create();

        $names = array_column(
            array_column($this->getJson('/api/v1/domains')->assertOk()->json('data'), 'domain'),
            'name'
        );

        $this->assertSame(['visible.com'], $names);
    }

    /**
     * An under-offer listing stays visible on purpose: hiding a name the moment
     * one offer arrives would let a lowball take it off the market.
     */
    #[Test]
    public function an_under_offer_listing_remains_visible(): void
    {
        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('negotiating.com')->create())
            ->underOffer()
            ->create();

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.status', 'under_offer');
    }

    /**
     * Whether a registrar lock is still in force is decided server-side.
     *
     * The client must not compare the date itself: a device with a wrong clock
     * would tell a buyer a lapsed lock is active, or that an active one is not.
     */
    #[Test]
    public function the_response_states_whether_a_transfer_lock_is_still_in_force(): void
    {
        $locked = Domain::factory()->verified()->named('locked.com')->create();
        $locked->forceFill(['transfer_lock_until' => now()->addDays(30)])->save();
        Listing::factory()->forDomain($locked)->active()->create();

        $clear = Domain::factory()->verified()->named('clear.com')->create();
        $clear->forceFill(['transfer_lock_until' => now()->subDays(30)])->save();
        Listing::factory()->forDomain($clear)->active()->create();

        $byName = [];

        foreach ($this->getJson('/api/v1/domains')->assertOk()->json('data') as $listing) {
            $byName[$listing['domain']['name']] = $listing['domain'];
        }

        $this->assertTrue($byName['locked.com']['is_transfer_locked']);
        $this->assertFalse($byName['clear.com']['is_transfer_locked']);

        // The date is still sent in both cases; only the flag differs.
        $this->assertNotNull($byName['clear.com']['transfer_available_from']);
    }

    #[Test]
    public function a_published_listing_states_that_ownership_is_verified(): void
    {
        $this->listing('proved.com');

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('data.0.domain.ownership_verified', true);
    }

    /**
     * The seller's floor and any enforcement state are absent from the public
     * payload. Asserted as missing keys rather than by value, because a null
     * would still tell a buyer the field exists.
     */
    #[Test]
    public function the_public_payload_carries_no_private_seller_state(): void
    {
        $this->listing('private.com', ['min_offer_minor' => 500_00]);

        $listing = $this->getJson('/api/v1/domains')->assertOk()->json('data.0');

        $this->assertArrayNotHasKey('min_offer_minor', $listing);
        $this->assertArrayNotHasKey('min_offer', $listing);
        $this->assertArrayNotHasKey('suspended_at', $listing);
        $this->assertArrayNotHasKey('suspension_reason', $listing);

        // Nor the primary key: listings are addressed by name and public id.
        $this->assertArrayNotHasKey('domain_id', $listing);
        $this->assertArrayNotHasKey('user_id', $listing);
        $this->assertArrayNotHasKey('seller_profile_id', $listing);
    }

    #[Test]
    public function a_make_offer_listing_publishes_no_price(): void
    {
        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('offersonly.com')->create())
            ->makeOffer()
            ->create();

        $this->getJson('/api/v1/domains')
            ->assertOk()
            // Null rather than zero: a zero would be a lie and a placeholder
            // would break every price filter.
            ->assertJsonPath('data.0.price', null)
            ->assertJsonPath('data.0.accepts_offers', true)
            ->assertJsonPath('data.0.is_buy_now', false);
    }

    #[Test]
    public function prices_are_money_objects(): void
    {
        $this->listing('priced.com', ['asking_price_minor' => 1_250_000]);

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('data.0.price.minor', 1_250_000)
            ->assertJsonPath('data.0.price.currency', 'USD')
            ->assertJsonPath('data.0.price.amount', '12500.00');
    }

    #[Test]
    public function it_searches_by_partial_label(): void
    {
        $this->listing('coffeehub.com');
        $this->listing('teahouse.com');

        $this->getJson('/api/v1/domains?q=coffee')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.name', 'coffeehub.com');
    }

    /**
     * A buyer pastes a URL far more often than they type a bare name.
     */
    #[Test]
    public function a_pasted_url_finds_the_exact_name(): void
    {
        $this->listing('coffee.com');
        $this->listing('coffeehub.com');

        $this->getJson('/api/v1/domains?q='.urlencode('https://www.coffee.com/pricing'))
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.name', 'coffee.com');
    }

    /**
     * A wildcard typed by a buyer is a literal character, not syntax. Without
     * escaping, searching "%" matches the entire marketplace.
     */
    #[Test]
    public function like_wildcards_in_a_search_term_are_treated_literally(): void
    {
        $this->listing('discount.com');
        $this->listing('fiftypercent.com');

        $this->getJson('/api/v1/domains?q=%25')
            ->assertOk()
            ->assertJsonPath('meta.total', 0);
    }

    #[Test]
    public function it_filters_by_tld(): void
    {
        Tld::factory()->create(['tld' => 'io']);

        $this->listing('alpha.com');
        $this->listing('beta.io');

        $this->getJson('/api/v1/domains?tld=io')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.tld', 'io');
    }

    #[Test]
    public function it_filters_by_category(): void
    {
        $category = DomainCategory::factory()->create(['slug' => 'finance']);

        $this->listing('ledger.com', ['domain_category_id' => $category->getKey()]);
        $this->listing('unrelated.com');

        $this->getJson('/api/v1/domains?category=finance')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.name', 'ledger.com');
    }

    #[Test]
    public function it_filters_by_price_range(): void
    {
        // $10, $5,000 and $50,000 respectively.
        $this->listing('cheap.com', ['asking_price_minor' => 1_000]);
        $this->listing('mid.com', ['asking_price_minor' => 500_000]);
        $this->listing('dear.com', ['asking_price_minor' => 5_000_000]);

        // Bounds are major units, as a buyer types them: $100 to $10,000.
        $names = array_column(
            array_column(
                $this->getJson('/api/v1/domains?min_price=100&max_price=10000')->assertOk()->json('data'),
                'domain'
            ),
            'name'
        );

        $this->assertSame(['mid.com'], $names);
    }

    /**
     * A price filter is a budget, and a listing that publishes no price cannot
     * be known to meet it.
     */
    #[Test]
    public function a_price_filter_excludes_listings_with_no_price(): void
    {
        $this->listing('priced.com', ['asking_price_minor' => 100_000]);
        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('unpriced.com')->create())
            ->makeOffer()
            ->create();

        $this->getJson('/api/v1/domains?max_price=100000')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.name', 'priced.com');
    }

    #[Test]
    public function it_filters_out_hyphens_and_digits(): void
    {
        $this->listing('clean.com');
        $this->listing('with-hyphen.com');
        $this->listing('digits24.com');

        $names = array_column(
            array_column(
                $this->getJson('/api/v1/domains?exclude_hyphens=1&exclude_digits=1')->assertOk()->json('data'),
                'domain'
            ),
            'name'
        );

        $this->assertSame(['clean.com'], $names);
    }

    #[Test]
    public function it_filters_by_label_length(): void
    {
        $this->listing('ab.com');
        $this->listing('abcdefghijkl.com');

        $this->getJson('/api/v1/domains?max_length=4')
            ->assertOk()
            ->assertJsonPath('meta.total', 1)
            ->assertJsonPath('data.0.domain.name', 'ab.com');
    }

    /**
     * Sorting cheapest-first must not lead the page with listings that have no
     * price at all.
     */
    #[Test]
    public function priceless_listings_sort_last_in_both_price_directions(): void
    {
        $this->listing('cheap.com', ['asking_price_minor' => 10_000]);
        $this->listing('dear.com', ['asking_price_minor' => 900_000]);
        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('offers.com')->create())
            ->makeOffer()
            ->create();

        $ascending = array_column(
            array_column($this->getJson('/api/v1/domains?sort=price_asc')->assertOk()->json('data'), 'domain'),
            'name'
        );

        $this->assertSame(['cheap.com', 'dear.com', 'offers.com'], $ascending);

        $descending = array_column(
            array_column($this->getJson('/api/v1/domains?sort=price_desc')->assertOk()->json('data'), 'domain'),
            'name'
        );

        $this->assertSame(['dear.com', 'cheap.com', 'offers.com'], $descending);
    }

    /**
     * Promotion leads the default ordering, but not a price sort: someone
     * sorting by price asked a precise question.
     */
    #[Test]
    public function featured_listings_lead_the_default_ordering_but_not_a_price_sort(): void
    {
        $this->listing('ordinary.com', ['asking_price_minor' => 10_000]);

        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('promoted.com')->create())
            ->featured()
            ->create(['asking_price_minor' => 900_000]);

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('data.0.domain.name', 'promoted.com');

        $this->getJson('/api/v1/domains?sort=price_asc')
            ->assertOk()
            ->assertJsonPath('data.0.domain.name', 'ordinary.com');
    }

    /**
     * Placement is bought for a period. A flag left set after it lapsed would
     * give away promotion nobody paid for.
     */
    #[Test]
    public function lapsed_featured_placement_is_not_honoured(): void
    {
        $listing = $this->listing('lapsed.com');
        $listing->forceFill([
            'is_featured' => true,
            'featured_until' => now()->subDay(),
        ])->save();

        $this->getJson('/api/v1/domains')
            ->assertOk()
            ->assertJsonPath('data.0.is_featured', false);
    }

    #[Test]
    public function a_listing_is_readable_by_its_domain_name(): void
    {
        $this->listing('detail.com', ['asking_price_minor' => 250_000]);

        $this->getJson('/api/v1/domains/detail.com')
            ->assertOk()
            ->assertJsonPath('data.domain.name', 'detail.com')
            ->assertJsonPath('data.price.minor', 250_000);
    }

    /**
     * A name that is not for sale, a name that does not exist and a string that
     * is not a domain all answer identically. Distinguishing them would confirm
     * which private inventory exists.
     */
    #[Test]
    public function anything_not_publicly_for_sale_answers_the_same_way(): void
    {
        Listing::factory()->forDomain(Domain::factory()->verified()->named('hidden.com')->create())->create();

        $this->getJson('/api/v1/domains/hidden.com')->assertNotFound();
        $this->getJson('/api/v1/domains/never-existed.com')->assertNotFound();
        $this->getJson('/api/v1/domains/notadomain')->assertNotFound();
    }

    #[Test]
    public function a_detail_view_is_counted(): void
    {
        $listing = $this->listing('counted.com', ['views_count' => 0]);

        $this->getJson('/api/v1/domains/counted.com')->assertOk();

        $this->assertSame(1, (int) $listing->fresh()->views_count);
    }

    /**
     * @return list<array{0: string}>
     */
    public static function invalidFilterProvider(): array
    {
        return [
            'page size above the cap' => ['per_page=500'],
            'inverted price range' => ['min_price=900&max_price=100'],
            'inverted length range' => ['min_length=10&max_length=2'],
            'unknown sort key' => ['sort=id%3Bdrop'],
            'unknown tld' => ['tld=zzz'],
            'unknown category' => ['category=not-a-category'],
            'unknown listing type' => ['type=barter'],
            'length beyond a registry label' => ['max_length=99'],
        ];
    }

    /**
     * The most exposed endpoint in the application. An unbounded page size is a
     * denial-of-service and an unrecognised sort key reaching the query builder
     * is an injection surface, so both are refused rather than ignored.
     */
    #[Test]
    #[DataProvider('invalidFilterProvider')]
    public function it_refuses_an_invalid_filter(string $query): void
    {
        $this->getJson('/api/v1/domains?'.$query)->assertStatus(422);
    }

    /**
     * An inverted range returns nothing, which is indistinguishable from an
     * empty marketplace -- the wrong thing to tell someone who simply flipped
     * two fields.
     */
    #[Test]
    public function an_inverted_range_is_reported_as_a_field_error(): void
    {
        $this->getJson('/api/v1/domains?min_price=900&max_price=100')
            ->assertStatus(422)
            ->assertJsonValidationErrors('min_price');
    }

    /**
     * Cost must not grow with the number of results. A card reads its domain,
     * TLD, category and seller -- and the seller's own resource reads
     * verification badges, which is where the previous N+1 in this codebase was.
     */
    #[Test]
    public function the_search_grid_costs_a_constant_number_of_queries(): void
    {
        $category = DomainCategory::factory()->create(['slug' => 'finance']);

        $this->assertQueryCountDoesNotScale(
            seed: function (int $count) use ($category): void {
                for ($i = 0; $i < $count; $i++) {
                    $user = User::factory()->create();
                    $domain = Domain::factory()->verified()->ownedBy($user)->create();

                    Listing::factory()
                        ->forDomain($domain)
                        ->active()
                        ->create(['domain_category_id' => $category->getKey()]);
                }
            },
            operation: function (int $count): void {
                $this->getJson('/api/v1/domains?per_page=60')
                    ->assertOk()
                    ->assertJsonCount($count, 'data');
            },
        );
    }

    /**
     * The counters exist so a browse grid can show a number beside a category
     * without an aggregate per card. That is only worth doing if the number
     * matches what clicking through actually returns.
     */
    #[Test]
    public function the_catalogue_counters_track_publicly_visible_listings(): void
    {
        $category = DomainCategory::factory()->create(['slug' => 'finance']);

        $listing = $this->listing('counted.com', ['domain_category_id' => $category->getKey()]);

        // Looked up after the listing exists: the `.com` row is created by the
        // domain factory, so there is nothing to find before that point.
        $tld = Tld::query()->where('tld', 'com')->firstOrFail();

        // A draft in the same buckets must not be counted.
        Listing::factory()
            ->forDomain(Domain::factory()->verified()->named('draft.com')->create())
            ->create(['domain_category_id' => $category->getKey()]);

        $this->assertSame(1, (int) $category->fresh()->listings_count);
        $this->assertSame(1, (int) $tld->fresh()->listings_count);

        /*
         * Withdrawing it takes the count back down. `status` is deliberately not
         * mass-assignable -- a state change is a transition with rules, not a
         * field -- so it is set directly rather than through update().
         */
        $listing->status = ListingStatus::Withdrawn;
        $listing->save();

        $this->assertSame(0, (int) $category->fresh()->listings_count);
        $this->assertSame(0, (int) $tld->fresh()->listings_count);
    }
}
