<?php

declare(strict_types=1);

namespace App\Services;

use App\Models\Artwork;
use App\Models\RankList;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

/**
 * RankingService — Skinbase rank_v2
 *
 * Responsibilities:
 *  1. Score computation  — turn raw artwork signals into three float scores.
 *  2. Diversity filtering — cap items per author while keeping rank order.
 *  3. List read / cache  — serve ranked lists from Redis, falling back to DB,
 *     and ultimately to latest-first if no list is built yet.
 *
 * V2 enhancements:
 *  - Shares and comments are included in engagement scoring
 *  - Engagement velocity (24h burst) boosts trending artworks
 */
final class RankingService
{
    // ── Score computation ──────────────────────────────────────────────────

    /**
     * Compute all three ranking scores for a single artwork data row.
     *
     * V2: includes shares, comments, and velocity boost.
     *
     * @param object $row  stdClass with fields:
     *   views_7d, favourites_7d, downloads_7d,
     *   views_all, favourites_all, downloads_all,
     *   views_24h, favourites_24h, downloads_24h,
     *   comments_count, shares_count, comments_24h, shares_24h,
     *   age_hours, tag_count, has_thumbnail (bool 0/1),
     *   is_public, is_approved
     * @return array{score_trending: float, score_new_hot: float, score_best: float}
     */
    public function computeScores(object $row): array
    {
        $cfg = config('ranking');

        $wV = (float) $cfg['weights']['views'];
        $wF = (float) $cfg['weights']['favourites'];
        $wD = (float) $cfg['weights']['downloads'];

        // V2 weights for shares + comments (from v2 config, with defaults)
        $wC = (float) ($cfg['v2']['weights']['comments'] ?? 3.0);
        $wS = (float) ($cfg['v2']['weights']['shares']   ?? 4.0);

        // 3.1 Base engagement (7-day window) — V2: includes shares & comments
        $E = ($wV * log(1 + (float) $row->views_7d))
           + ($wF * log(1 + (float) $row->favourites_7d))
           + ($wD * log(1 + (float) $row->downloads_7d))
           + ($wC * log(1 + (float) ($row->comments_24h ?? 0) * 7))
           + ($wS * log(1 + (float) ($row->shares_24h ?? 0) * 7));

        // Base engagement (all-time, for "best" score)
        $E_all = ($wV * log(1 + (float) $row->views_all))
               + ($wF * log(1 + (float) $row->favourites_all))
               + ($wD * log(1 + (float) $row->downloads_all))
               + ($wC * log(1 + (float) ($row->comments_count ?? 0)))
               + ($wS * log(1 + (float) ($row->shares_count ?? 0)));

        // 3.2 Freshness decay
        $ageH = max(0.0, (float) $row->age_hours);
        $decayTrending = exp(-$ageH / (float) $cfg['half_life']['trending']);
        $decayNewHot   = exp(-$ageH / (float) $cfg['half_life']['new_hot']);
        $decayBest     = exp(-$ageH / (float) $cfg['half_life']['best']);

        // 3.3 Quality modifier
        $tagCount  = (int) $row->tag_count;
        $hasTags   = $tagCount > 0;
        $hasThumb  = (bool) $row->has_thumbnail;
        $isVisible = (bool) $row->is_public && (bool) $row->is_approved;

        $Q = 1.0;
        if ($hasTags)  { $Q += (float) $cfg['quality']['has_tags']; }
        if ($hasThumb) { $Q += (float) $cfg['quality']['has_thumbnail']; }
        $Q += (float) $cfg['quality']['tag_count_bonus']
            * (min($tagCount, (int) $cfg['quality']['tag_count_max'])
               / (float) $cfg['quality']['tag_count_max']);
        if (! $isVisible) { $Q -= (float) $cfg['quality']['penalty_hidden']; }

        // 3.4 Novelty boost (New & Hot)
        $noveltyW = (float) $cfg['novelty_weight'];
        $novelty  = 1.0 + $noveltyW * exp(-$ageH / 24.0);

        // 3.5 Velocity boost (V2) — 24h engagement burst
        $vw = $cfg['v2']['velocity_weights'] ?? ['views' => 1, 'favourites' => 3, 'comments' => 4, 'shares' => 5];
        $velocityRaw = ((float) ($vw['views'] ?? 1)      * (float) ($row->views_24h ?? 0))
                     + ((float) ($vw['favourites'] ?? 3) * (float) ($row->favourites_24h ?? 0))
                     + ((float) ($vw['comments'] ?? 4)   * (float) ($row->comments_24h ?? 0))
                     + ((float) ($vw['shares'] ?? 5)     * (float) ($row->shares_24h ?? 0));
        $velocityBoost = $velocityRaw * (float) ($cfg['v2']['velocity_multiplier'] ?? 0.5);

        // Anti-spam damping on trending score only
        $spamFactor = 1.0;
        $spam = $cfg['spam'];
        if (
            (float) $row->views_24h > (float) $spam['views_24h_threshold']
            && (float) $row->views_24h > 0
        ) {
            $rF = (float) ($row->favourites_24h ?? 0) / (float) $row->views_24h;
            $rD = (float) ($row->downloads_24h ?? 0)  / (float) $row->views_24h;
            if ($rF < (float) $spam['fav_ratio_threshold']
                && $rD < (float) $spam['dl_ratio_threshold']
            ) {
                $spamFactor = (float) $spam['trending_penalty_factor'];
            }
        }

        $scoreTrending = ($E * $decayTrending * (1.0 + $Q) * $spamFactor) + $velocityBoost;
        $scoreNewHot   = ($E * $decayNewHot   * $novelty   * (1.0 + $Q)) + ($velocityBoost * 0.7);
        $scoreBest     = $E_all * $decayBest * (1.0 + $Q);

        return [
            'score_trending' => max(0.0, $scoreTrending),
            'score_new_hot'  => max(0.0, $scoreNewHot),
            'score_best'     => max(0.0, $scoreBest),
        ];
    }

    // ── Diversity filtering ────────────────────────────────────────────────

    /**
     * Apply author-diversity cap to an already-ordered candidate array.
     *
     * @param  array  $candidates  Ordered array, each element must have artwork_id + user_id.
     * @param  int    $maxPerAuthor
     * @param  int    $listSize
     * @return array  Filtered, at most $listSize elements.
     */
    public function applyDiversity(array $candidates, int $maxPerAuthor, int $listSize): array
    {
        $result      = [];
        $authorCount = [];

        foreach ($candidates as $item) {
            $uid = (int) ($item->user_id ?? $item['user_id'] ?? 0);

            if (($authorCount[$uid] ?? 0) >= $maxPerAuthor) {
                continue;
            }

            $result[] = $item;
            $authorCount[$uid] = ($authorCount[$uid] ?? 0) + 1;

            if (count($result) >= $listSize) {
                break;
            }
        }

        return $result;
    }

    // ── List retrieval ─────────────────────────────────────────────────────

    /**
     * Retrieve a ranked list of artwork IDs.
     *
     * Order of precedence:
     *  1. Redis cache
     *  2. rank_lists table
     *  3. Fallback: latest-first from artworks
     *
     * @param  string   $scopeType  global | category | content_type
     * @param  int|null $scopeId    category.id or content_type.id, null for global
     * @param  string   $listType   trending | new_hot | best
     * @return array{ids: int[], computed_at: string|null, model_version: string, fallback: bool}
     */
    public function getList(string $scopeType, ?int $scopeId, string $listType): array
    {
        $ttl        = (int) config('ranking.cache.ttl', 900);
        $cacheKey   = $this->cacheKey($scopeType, $scopeId, $listType);
        $modelVer   = config('ranking.model_version', 'rank_v1');

        // 1. Cache
        $cached = Cache::get($cacheKey);
        if ($cached !== null) {
            return $cached;
        }

        // 2. DB
        $rankList = RankList::where('scope_type', $scopeType)
            ->where('scope_id', RankList::resolveScope($scopeId))
            ->where('list_type', $listType)
            ->where('model_version', $modelVer)
            ->first();

        if ($rankList !== null) {
            $payload = [
                'ids'           => $rankList->artwork_ids,
                'computed_at'   => $rankList->computed_at?->toIso8601String(),
                'model_version' => $rankList->model_version,
                'fallback'      => false,
            ];
            Cache::put($cacheKey, $payload, $ttl);

            return $payload;
        }

        // 3. Fallback — latest published artworks
        Log::info('RankingService: no rank list found, falling back to latest', [
            'scope_type' => $scopeType,
            'scope_id'   => $scopeId,
            'list_type'  => $listType,
        ]);

        $ids = $this->fallbackIds($scopeType, $scopeId);

        return [
            'ids'           => $ids,
            'computed_at'   => null,
            'model_version' => 'fallback',
            'fallback'      => true,
        ];
    }

    /**
     * Bust the Redis cache for a specific scope/type combination.
     */
    public function bustCache(string $scopeType, ?int $scopeId, string $listType): void
    {
        Cache::forget($this->cacheKey($scopeType, $scopeId, $listType));
    }

    /**
     * Bust all cache keys for a list type across scopes.
     * (Convenience — used after full rebuild.)
     */
    public function bustAllCaches(string $modelVersion): void
    {
        foreach (['trending', 'new_hot', 'best'] as $listType) {
            Cache::forget($this->cacheKey('global', null, $listType));
        }
        // Category and content_type caches are keyed with scope_id, so they expire
        // naturally after TTL or get replaced on next request.
    }

    /**
     * Build the Redis cache key for a list.
     *
     * Format: rank:list:{scope_type}:{scope_id|global}:{list_type}:{model_version}
     */
    public function cacheKey(string $scopeType, ?int $scopeId, string $listType): string
    {
        $prefix  = config('ranking.cache.prefix', 'rank');
        $version = config('ranking.model_version', 'rank_v1');
        $sid     = $scopeId !== null ? (string) $scopeId : 'global';

        return "{$prefix}:list:{$scopeType}:{$sid}:{$listType}:{$version}";
    }

    // ── Private helpers ────────────────────────────────────────────────────

    /**
     * Latest-first fallback IDs (public, approved artworks).
     * Applies category/content_type filter when relevant.
     *
     * @return int[]
     */
    private function fallbackIds(string $scopeType, ?int $scopeId): array
    {
        $listSize = (int) config('ranking.diversity.list_size', 50);

        $query = Artwork::query()
            ->select('artworks.id')
            ->where('artworks.is_public', true)
            ->where('artworks.is_approved', true)
            ->whereNull('artworks.deleted_at')
            ->whereNotNull('artworks.published_at')
            ->orderByDesc('artworks.published_at')
            ->limit($listSize);

        if ($scopeType === 'category' && $scopeId !== null) {
            $query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
                  ->where('artwork_category.category_id', $scopeId);
        }

        if ($scopeType === 'content_type' && $scopeId !== null) {
            $query->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
                  ->join('categories', 'categories.id', '=', 'artwork_category.category_id')
                  ->where('categories.content_type_id', $scopeId);
        }

        return $query->pluck('artworks.id')->map(fn ($id) => (int) $id)->all();
    }

    // ── Signal query (used by RankComputeArtworkScoresJob) ─────────────────

    /**
     * Return a query builder that selects all artwork signals needed for score
     * computation.  Results are NOT paginated — callers chunk them.
     *
     * V2 columns returned:
     *   id, user_id, published_at, is_public, is_approved,
     *   has_thumbnail,
     *   views_7d, downloads_7d, views_24h, downloads_24h,
     *   views_all, downloads_all, favourites_all,
     *   favourites_7d, favourites_24h,
     *   comments_count, shares_count, comments_24h, shares_24h,
     *   tag_count,
     *   age_hours
     */
    public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
    {
        $hasSharesTable   = \Illuminate\Support\Facades\Schema::hasTable('artwork_shares');
        $hasCommentsTable = \Illuminate\Support\Facades\Schema::hasTable('artwork_comments');

        $query = DB::table('artworks as a')
            ->select([
                'a.id',
                'a.user_id',
                'a.published_at',
                'a.is_public',
                'a.is_approved',
                DB::raw('(a.thumb_ext IS NOT NULL AND a.thumb_ext != "") AS has_thumbnail'),
                DB::raw('COALESCE(ast.views_7d, 0)       AS views_7d'),
                DB::raw('COALESCE(ast.downloads_7d, 0)   AS downloads_7d'),
                DB::raw('COALESCE(ast.views_24h, 0)      AS views_24h'),
                DB::raw('COALESCE(ast.downloads_24h, 0)  AS downloads_24h'),
                DB::raw('COALESCE(ast.views, 0)          AS views_all'),
                DB::raw('COALESCE(ast.downloads, 0)      AS downloads_all'),
                DB::raw('COALESCE(ast.favorites, 0)      AS favourites_all'),
                DB::raw('COALESCE(fav7.cnt, 0)           AS favourites_7d'),
                DB::raw('COALESCE(fav1.cnt, 0)           AS favourites_24h'),
                // V2: comments + shares
                DB::raw(
                    $hasCommentsTable
                        ? 'COALESCE(cc_all.cnt, 0) AS comments_count'
                        : '0 AS comments_count'
                ),
                DB::raw(
                    $hasSharesTable
                        ? 'COALESCE(sc_all.cnt, 0) AS shares_count'
                        : '0 AS shares_count'
                ),
                DB::raw(
                    $hasCommentsTable
                        ? 'COALESCE(cc24.cnt, 0) AS comments_24h'
                        : '0 AS comments_24h'
                ),
                DB::raw(
                    $hasSharesTable
                        ? 'COALESCE(sc24.cnt, 0) AS shares_24h'
                        : '0 AS shares_24h'
                ),
                DB::raw('COALESCE(tc.tag_count, 0)       AS tag_count'),
                DB::raw('GREATEST(TIMESTAMPDIFF(HOUR, a.published_at, NOW()), 0) AS age_hours'),
            ])
            ->leftJoin('artwork_stats as ast', 'ast.artwork_id', '=', 'a.id')
            // Favourites (7 days)
            ->leftJoinSub(
                DB::table('artwork_favourites')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 7 DAY)'))
                    ->groupBy('artwork_id'),
                'fav7',
                'fav7.artwork_id', '=', 'a.id'
            )
            // Favourites (24 hours)
            ->leftJoinSub(
                DB::table('artwork_favourites')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
                    ->groupBy('artwork_id'),
                'fav1',
                'fav1.artwork_id', '=', 'a.id'
            )
            // Tag count
            ->leftJoinSub(
                DB::table('artwork_tag')
                    ->select('artwork_id', DB::raw('COUNT(*) as tag_count'))
                    ->groupBy('artwork_id'),
                'tc',
                'tc.artwork_id', '=', 'a.id'
            )
            ->where('a.is_public', 1)
            ->where('a.is_approved', 1)
            ->whereNull('a.deleted_at')
            ->whereNotNull('a.published_at');

        // V2: Comments (all-time + 24h)
        if ($hasCommentsTable) {
            $query->leftJoinSub(
                DB::table('artwork_comments')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->whereNull('deleted_at')
                    ->groupBy('artwork_id'),
                'cc_all',
                'cc_all.artwork_id', '=', 'a.id'
            );
            $query->leftJoinSub(
                DB::table('artwork_comments')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->whereNull('deleted_at')
                    ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
                    ->groupBy('artwork_id'),
                'cc24',
                'cc24.artwork_id', '=', 'a.id'
            );
        }

        // V2: Shares (all-time + 24h)
        if ($hasSharesTable) {
            $query->leftJoinSub(
                DB::table('artwork_shares')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->groupBy('artwork_id'),
                'sc_all',
                'sc_all.artwork_id', '=', 'a.id'
            );
            $query->leftJoinSub(
                DB::table('artwork_shares')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->where('created_at', '>=', DB::raw('DATE_SUB(NOW(), INTERVAL 1 DAY)'))
                    ->groupBy('artwork_id'),
                'sc24',
                'sc24.artwork_id', '=', 'a.id'
            );
        }

        return $query;
    }
}
