<?php

declare(strict_types=1);

namespace App\Services\Marketplace;

use App\Enums\ListingStatus;
use App\Models\DomainCategory;
use App\Models\Tld;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;

/**
 * Maintains the denormalised `listings_count` columns.
 *
 * Those columns exist so a browse grid can show "142 names" beside a category
 * without an aggregate per card. That is only worth doing if the number is
 * right, so the recount lives here and every writer goes through it.
 *
 * Recounted rather than incremented. An increment is one lost update away from
 * being permanently wrong with no way to notice; a recount is a single indexed
 * aggregate over one TLD or category, which is cheap at the rate listings
 * change and self-heals if anything ever writes around the observer.
 *
 * These are advisory display values. Nothing financial reads them.
 */
final class ListingCounters
{
    public function __construct(
        private readonly CatalogueCache $cache,
    ) {}

    public function recountCategory(int $categoryId): void
    {
        DomainCategory::query()->whereKey($categoryId)->update([
            'listings_count' => $this->visibleCount(
                fn (Builder $query): Builder => $query->where('listings.domain_category_id', $categoryId)
            ),
        ]);

        $this->cache->forgetCategories();
    }

    public function recountTld(int $tldId): void
    {
        Tld::query()->whereKey($tldId)->update([
            'listings_count' => $this->visibleCount(
                fn (Builder $query): Builder => $query
                    ->join('domains', 'domains.id', '=', 'listings.domain_id')
                    ->where('domains.tld_id', $tldId)
            ),
        ]);

        $this->cache->forgetTlds();
    }

    /**
     * Rebuilds every counter in two grouped queries.
     *
     * For bulk work -- a seeder, an import, a repair after something wrote
     * around the observer. Buckets with no visible listings are zeroed first,
     * because a category that lost its last listing has to fall to zero rather
     * than keep its old number.
     */
    public function resyncAll(): void
    {
        DomainCategory::query()->update(['listings_count' => 0]);
        Tld::query()->update(['listings_count' => 0]);

        $perCategory = $this->visibleQuery()
            ->whereNotNull('listings.domain_category_id')
            ->groupBy('listings.domain_category_id')
            ->pluck(DB::raw('count(*)'), 'listings.domain_category_id');

        foreach ($perCategory as $categoryId => $count) {
            DomainCategory::query()->whereKey((int) $categoryId)
                ->update(['listings_count' => (int) $count]);
        }

        $perTld = $this->visibleQuery()
            ->join('domains', 'domains.id', '=', 'listings.domain_id')
            ->groupBy('domains.tld_id')
            ->pluck(DB::raw('count(*)'), 'domains.tld_id');

        foreach ($perTld as $tldId => $count) {
            Tld::query()->whereKey((int) $tldId)->update(['listings_count' => (int) $count]);
        }

        $this->cache->forgetCategories();
        $this->cache->forgetTlds();
    }

    /**
     * @param  callable(Builder): Builder  $constrain
     */
    private function visibleCount(callable $constrain): int
    {
        return (int) $constrain($this->visibleQuery())->count();
    }

    /**
     * Only what a visitor could actually find.
     *
     * The number beside a category has to match the number of results clicking
     * it produces. Counting drafts and withdrawn listings would advertise
     * inventory that is not there, which reads as a broken filter.
     */
    private function visibleQuery(): Builder
    {
        $visible = array_map(
            fn (ListingStatus $status): string => $status->value,
            array_filter(
                ListingStatus::cases(),
                fn (ListingStatus $status): bool => $status->isPubliclyVisible(),
            ),
        );

        return DB::table('listings')
            ->whereNull('listings.deleted_at')
            ->whereNotNull('listings.published_at')
            ->whereIn('listings.status', array_values($visible));
    }
}
