<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1\Marketplace;

use App\Http\Requests\Marketplace\DomainSearchRequest;
use App\Http\Resources\ListingResource;
use App\Http\Resources\PublicSellerResource;
use App\Models\Listing;
use App\Support\Domain\DomainName;
use App\Support\Money\Money;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;

/**
 * Public domain search and the listing detail page.
 *
 * Unauthenticated by design: browsing must work without an account (spec
 * section 30), which also means this is the marketplace's most exposed
 * endpoint. Its cost is therefore bounded by the request validation rather than
 * by trust -- page size is capped, every filter is whitelisted, and no
 * user-supplied string reaches an ORDER BY.
 *
 * Deliberately not cached. The catalogue endpoints are, because a TLD list is
 * the same for everyone; a filtered search is close to unique per visitor, so
 * caching it would fill the store with entries read once.
 */
final class DomainSearchController extends Controller
{
    /**
     * Relations every listing card reads.
     *
     * Named once because there are two entry points, and a card that renders
     * correctly on the detail page but runs a query per row on the grid is the
     * classic way this goes wrong. Lazy loading is prevented application-wide,
     * so a missing relation here fails loudly in development.
     *
     * The seller's own requirements are taken from PublicSellerResource rather
     * than repeated: that resource reads `user.verifications` for its trust
     * badges, and a copy of that list here would be one refactor away from
     * being wrong.
     *
     * @return list<string>
     */
    private static function cardRelations(): array
    {
        return [
            'domain.tld',
            'category',
            'seller',
            ...array_map(
                fn (string $relation): string => 'seller.'.$relation,
                PublicSellerResource::relations(),
            ),
        ];
    }

    public function index(DomainSearchRequest $request): AnonymousResourceCollection
    {
        $query = Listing::query()
            ->publiclyVisible()
            ->with(self::cardRelations())
            /*
             * Joined rather than filtered through a relation subquery: the name,
             * length, TLD and character-class filters all live on `domains`, and
             * every one of them is used together with an ORDER BY that also has
             * to reach that table.
             */
            ->join('domains', 'domains.id', '=', 'listings.domain_id')
            ->select('listings.*');

        $this->applySearchTerm($query, $request->searchTerm());
        $this->applyFilters($query, $request);
        $this->applySort($query, $request->sort(), $request->searchTerm());

        $listings = $query->paginate($request->perPage())->withQueryString();

        return ListingResource::collection($listings);
    }

    /**
     * One listing, addressed by its domain name.
     *
     * The name is the identifier a buyer already has, and `/domain/example.com`
     * is the URL shape fixed before anything indexed it. The public id exists
     * for API callers but is not what this route binds on.
     */
    public function show(string $name): JsonResponse
    {
        try {
            $parsed = DomainName::parse($name);
        } catch (InvalidArgumentException) {
            /*
             * An unparseable name is a 404 rather than a 422. From outside there
             * is no difference between "that is not a domain" and "we do not
             * have it", and the shapes of the two answers should not differ
             * either.
             */
            abort(404);
        }

        $listing = Listing::query()
            ->publiclyVisible()
            ->with(self::cardRelations())
            ->whereHas('domain', fn (Builder $query) => $query->where('name', $parsed->full()))
            ->first();

        abort_if($listing === null, 404);

        $this->recordView($listing);

        return (new ListingResource($listing))->response();
    }

    /**
     * Matches the term against the name, the label, and the listing's title.
     *
     * An exact name is tried as a name; anything else is treated as a word to
     * find inside a label. The leading wildcard means this cannot use the index
     * on `domains.name` -- accepted for now, and the reason a full-text index is
     * on the list for the search phase rather than something to fake here.
     *
     * @param  Builder<Listing>  $query
     */
    private function applySearchTerm(Builder $query, ?string $term): void
    {
        if ($term === null) {
            return;
        }

        $escaped = $this->escapeLike(strtolower($term));

        $query->where(function (Builder $query) use ($term, $escaped): void {
            /*
             * A term that parses as a full domain is matched exactly as well, so
             * pasting "coffee.com" finds that name rather than every listing
             * whose label contains "coffee.com" (which is none).
             */
            if (str_contains($term, '.') && DomainName::isParsable($term)) {
                $query->orWhere('domains.name', DomainName::parse($term)->full());
            }

            $query->orWhere('domains.label', 'like', "%{$escaped}%")
                ->orWhere('listings.title', 'like', "%{$escaped}%");
        });
    }

    /**
     * @param  Builder<Listing>  $query
     */
    private function applyFilters(Builder $query, DomainSearchRequest $request): void
    {
        $query
            ->when($request->filled('tld'), fn (Builder $query) => $query->whereIn(
                'domains.tld_id',
                DB::table('tlds')->where('tld', strtolower((string) $request->input('tld')))->select('id')
            ))
            ->when($request->filled('category'), fn (Builder $query) => $query->whereIn(
                'listings.domain_category_id',
                DB::table('domain_categories')->where('slug', $request->input('category'))->select('id')
            ))
            ->when($request->filled('type'), fn (Builder $query) => $query->where(
                'listings.type',
                $request->input('type')
            ))
            ->when($request->filled('min_length'), fn (Builder $query) => $query->where(
                'domains.label_length',
                '>=',
                $request->integer('min_length')
            ))
            ->when($request->filled('max_length'), fn (Builder $query) => $query->where(
                'domains.label_length',
                '<=',
                $request->integer('max_length')
            ))
            ->when($request->boolean('exclude_hyphens'), fn (Builder $query) => $query->where('domains.has_hyphen', false))
            ->when($request->boolean('exclude_digits'), fn (Builder $query) => $query->where('domains.has_digit', false));

        if ($request->boolean('premium_tld')) {
            $query->whereIn('domains.tld_id', DB::table('tlds')->where('is_premium', true)->select('id'));
        }

        /*
         * Price bounds arrive in major units, as a buyer types them, and are
         * converted through Money so the string-to-minor conversion happens in
         * the one place that is tested for it.
         *
         * A price filter also excludes listings that publish no price. That is
         * the honest reading: someone filtering "under $500" is stating a
         * budget, and a make-offer listing cannot be known to meet it.
         */
        if ($request->filled('min_price')) {
            $query->where(
                'listings.asking_price_minor',
                '>=',
                Money::fromMajorString($this->asDecimalString($request->input('min_price')))->minor
            );
        }

        if ($request->filled('max_price')) {
            $query->where(
                'listings.asking_price_minor',
                '<=',
                Money::fromMajorString($this->asDecimalString($request->input('max_price')))->minor
            );
        }
    }

    /**
     * Ordering. The sort key is whitelisted by the request, so nothing
     * user-supplied reaches the ORDER BY except as a bound parameter.
     *
     * Featured placement leads every ordering except an explicit price sort.
     * Someone sorting by price asked a precise question and a promoted listing
     * appearing above a cheaper one would simply look wrong.
     *
     * @param  Builder<Listing>  $query
     */
    private function applySort(Builder $query, string $sort, ?string $term): void
    {
        $priceSort = in_array($sort, ['price_asc', 'price_desc'], true);

        if (! $priceSort) {
            /*
             * The stored flag alone is not trusted: placement expires, and a row
             * whose `featured_until` has passed must not keep its position.
             */
            $query->orderByRaw('(listings.is_featured = 1 and (listings.featured_until is null or listings.featured_until > ?)) desc', [now()]);
        }

        match ($sort) {
            'relevance' => $this->applyRelevanceSort($query, $term),
            'price_asc' => $query
                // Priceless listings sort last in BOTH directions rather than
                // leading the "cheapest first" page with no price at all.
                ->orderByRaw('listings.asking_price_minor is null')
                ->orderBy('listings.asking_price_minor'),
            'price_desc' => $query
                ->orderByRaw('listings.asking_price_minor is null')
                ->orderByDesc('listings.asking_price_minor'),
            'length_asc' => $query->orderBy('domains.label_length')->orderBy('domains.name'),
            'length_desc' => $query->orderByDesc('domains.label_length')->orderBy('domains.name'),
            default => $query->orderByDesc('listings.published_at'),
        };

        /*
         * A deterministic tie-break on every ordering. Without one, two listings
         * with the same price or timestamp can swap between pages, so a
         * paginating client sees one twice and another never.
         */
        $query->orderByDesc('listings.id');
    }

    /**
     * Exact name first, then names that start with the term, then the rest.
     *
     * @param  Builder<Listing>  $query
     */
    private function applyRelevanceSort(Builder $query, ?string $term): void
    {
        if ($term === null) {
            $query->orderByDesc('listings.published_at');

            return;
        }

        $lower = strtolower($term);
        $escaped = $this->escapeLike($lower);

        $query->orderByRaw(
            'case when domains.name = ? then 0 when domains.label = ? then 1 when domains.label like ? then 2 else 3 end',
            [$lower, $lower, "{$escaped}%"]
        );

        // Shorter names are more valuable, and a useful secondary within a band.
        $query->orderBy('domains.label_length');
    }

    /**
     * Escapes the wildcards LIKE treats as syntax.
     *
     * Without this, a search for "50%" matches everything, and one for "a_b"
     * matches "axb" -- not an injection, but a filter that quietly stops
     * meaning what the buyer typed.
     */
    private function escapeLike(string $value): string
    {
        return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $value);
    }

    /**
     * Money::fromMajorString takes a string on purpose -- a float argument has
     * already lost precision. The request gives a numeric string, normalised
     * here so "500" and "500.0" both become a valid decimal.
     */
    private function asDecimalString(mixed $value): string
    {
        return number_format((float) $value, 2, '.', '');
    }

    /**
     * Counts a detail-page view.
     *
     * A direct increment rather than a model save: this must not fire the
     * listing observers, and re-running the counter recount on a page view would
     * be absurd. There is no per-visitor deduplication yet, so this is a view
     * count and not a unique-visitor count -- it is displayed as such.
     */
    private function recordView(Listing $listing): void
    {
        DB::table('listings')->where('id', $listing->getKey())->increment('views_count');
    }
}
