<?php

declare(strict_types=1);

namespace App\Services\Ranking;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;

/**
 * ArtworkRankingService — Skinbase Nova Ranking Engine V2
 *
 * Intelligent scoring system combining:
 *   1. Base engagement (views, downloads, favourites, comments, shares)
 *   2. Author authority multiplier (followers + favourites received)
 *   3. Recency decay (half-life configurable, default 48h)
 *   4. Engagement velocity (last-24h activity burst)
 *
 * Final formula:
 *   ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost
 *
 * The service processes artworks in chunks and writes ranking_score +
 * engagement_velocity to the artwork_stats table.
 *
 * Designed to run via `php artisan nova:recalculate-rankings` every 30 minutes.
 */
final class ArtworkRankingService
{
    // ── Weight configuration keys ──────────────────────────────────────────

    /**
     * Default V2 weights — overridable via config('ranking.v2.weights.*')
     */
    private const DEFAULT_WEIGHTS = [
        'views'      => 0.2,
        'downloads'  => 1.5,
        'favourites' => 2.5,
        'comments'   => 3.0,
        'shares'     => 4.0,
    ];

    /**
     * Default velocity weights for 24h window signals.
     */
    private const DEFAULT_VELOCITY_WEIGHTS = [
        'views'      => 1.0,
        'favourites' => 3.0,
        'comments'   => 4.0,
        'shares'     => 5.0,
    ];

    private const DEFAULT_HALF_LIFE_HOURS = 48;
    private const DEFAULT_VELOCITY_MULTIPLIER = 0.5;
    private const DEFAULT_AUTHORITY_FACTOR = 0.05;
    private const DEFAULT_AUTHORITY_FAV_DIVISOR = 1000;

    // ── Public scoring methods (per-artwork row) ───────────────────────────

    /**
     * Calculate the base engagement score.
     *
     * base_score = (views × 0.2) + (downloads × 1.5) + (favourites × 2.5)
     *            + (comments × 3.0) + (shares × 4.0)
     */
    public function calculateBaseScore(object $row): float
    {
        $w = $this->weights();

        return ($w['views']      * (float) ($row->views_all ?? 0))
             + ($w['downloads']  * (float) ($row->downloads_all ?? 0))
             + ($w['favourites'] * (float) ($row->favourites_all ?? 0))
             + ($w['comments']   * (float) ($row->comments_count ?? 0))
             + ($w['shares']     * (float) ($row->shares_count ?? 0));
    }

    /**
     * Calculate the author authority multiplier.
     *
     * authority = log10(1 + followers_count) + (favourites_received / 1000)
     * multiplier = 1 + (authority × 0.05)
     */
    public function calculateAuthorityMultiplier(object $row): float
    {
        $followersCount = (float) ($row->author_followers_count ?? 0);
        $favReceived    = (float) ($row->author_favourites_received ?? 0);

        $factor     = (float) config('ranking.v2.authority_factor', self::DEFAULT_AUTHORITY_FACTOR);
        $favDivisor = (float) config('ranking.v2.authority_fav_divisor', self::DEFAULT_AUTHORITY_FAV_DIVISOR);

        $authority = log10(1 + $followersCount) + ($favReceived / $favDivisor);

        return 1.0 + ($authority * $factor);
    }

    /**
     * Calculate the recency decay factor.
     *
     * decay = 1 / (1 + (hours_since_upload / half_life))
     */
    public function calculateDecayFactor(object $row): float
    {
        $hours   = max(0.0, (float) ($row->age_hours ?? 0));
        $halfLife = (float) config('ranking.v2.half_life', self::DEFAULT_HALF_LIFE_HOURS);

        return 1.0 / (1.0 + ($hours / $halfLife));
    }

    /**
     * Calculate the 24h engagement velocity boost.
     *
     * velocity = (views_24h × 1) + (favourites_24h × 3) + (comments_24h × 4) + (shares_24h × 5)
     * boost = velocity × 0.5
     */
    public function calculateVelocityBoost(object $row): float
    {
        $vw = $this->velocityWeights();

        $velocity = ($vw['views']      * (float) ($row->views_24h ?? 0))
                  + ($vw['favourites'] * (float) ($row->favourites_24h ?? 0))
                  + ($vw['comments']   * (float) ($row->comments_24h ?? 0))
                  + ($vw['shares']     * (float) ($row->shares_24h ?? 0));

        $multiplier = (float) config('ranking.v2.velocity_multiplier', self::DEFAULT_VELOCITY_MULTIPLIER);

        return $velocity * $multiplier;
    }

    /**
     * Calculate the final ranking score for a single artwork row.
     *
     * ranking_score = (base_score × authority_multiplier × decay_factor) + velocity_boost
     *
     * @return array{ranking_score: float, engagement_velocity: float}
     */
    public function calculateFinalScore(object $row): array
    {
        $base      = $this->calculateBaseScore($row);
        $authority = $this->calculateAuthorityMultiplier($row);
        $decay     = $this->calculateDecayFactor($row);
        $velocity  = $this->calculateVelocityBoost($row);

        $recentScore   = $base * $authority * $decay;
        $rankingScore  = $recentScore + $velocity;

        return [
            'ranking_score'       => max(0.0, $rankingScore),
            'engagement_velocity' => max(0.0, $velocity),
        ];
    }

    // ── Bulk recalculation ─────────────────────────────────────────────────

    /**
     * Recalculate ranking_score and engagement_velocity for all public artworks.
     *
     * Uses chunked processing with a single pre-aggregated JOIN query.
     * Author authority data is cached per-author to avoid redundant lookups.
     *
     * @param  int $chunkSize  DB chunk size (default 500)
     * @return int Number of artworks updated
     */
    public function recalculateAll(int $chunkSize = 500): int
    {
        $total = 0;

        // Pre-load author authority data into memory (one query)
        $authorityCache = $this->loadAuthorAuthorityMap();

        $this->artworkSignalsQuery()
            ->orderBy('a.id')
            ->chunk($chunkSize, function ($rows) use ($authorityCache, &$total): void {
                $rows = collect($rows);
                if ($rows->isEmpty()) {
                    return;
                }

                $upserts = [];

                foreach ($rows as $row) {
                    /** @var object $row */
                    // Inject cached author authority data
                    $userId = (int) $row->user_id;
                    $row->author_followers_count     = $authorityCache[$userId]['followers'] ?? 0;
                    $row->author_favourites_received = $authorityCache[$userId]['fav_received'] ?? 0;

                    $scores = $this->calculateFinalScore($row);

                    $upserts[] = [
                        'artwork_id'          => (int) $row->id,
                        'ranking_score'       => round($scores['ranking_score'], 4),
                        'engagement_velocity' => round($scores['engagement_velocity'], 4),
                        'comments_count'      => (int) ($row->comments_count ?? 0),
                        'shares_count'        => (int) ($row->shares_count ?? 0),
                    ];
                }

                // Bulk upsert into artwork_stats
                if (! empty($upserts)) {
                    DB::table('artwork_stats')->upsert(
                        $upserts,
                        ['artwork_id'],
                        ['ranking_score', 'engagement_velocity', 'comments_count', 'shares_count']
                    );
                }

                $total += count($upserts);
            });

        Log::info('ArtworkRankingService V2: recalculation complete', [
            'total_updated' => $total,
        ]);

        return $total;
    }

    /**
     * Also update the existing rank_artwork_scores with V2 scores
     * so the RankBuildListsJob benefits from the new formula.
     */
    public function syncToRankScores(int $chunkSize = 500): int
    {
        $modelVersion = config('ranking.model_version', 'rank_v2');
        $total        = 0;
        $now          = now()->toDateTimeString();

        // Re-use the existing RankingService to compute the 3 scores,
        // but inject V2 signals (shares, comments, velocity) into the existing formula
        $this->artworkSignalsQuery()
            ->orderBy('a.id')
            ->chunk($chunkSize, function ($rows) use ($modelVersion, $now, &$total): void {
                $rows = collect($rows);
                if ($rows->isEmpty()) {
                    return;
                }

                $upserts = $rows->map(function ($row) use ($modelVersion, $now): array {
                    // Compute V2-enhanced scores for the 3 list types
                    $scores = $this->computeListScores($row);

                    return [
                        'artwork_id'      => (int) $row->id,
                        'score_trending'  => $scores['score_trending'],
                        'score_new_hot'   => $scores['score_new_hot'],
                        'score_best'      => $scores['score_best'],
                        'model_version'   => $modelVersion,
                        'computed_at'     => $now,
                    ];
                })->all();

                DB::table('rank_artwork_scores')->upsert(
                    $upserts,
                    ['artwork_id'],
                    ['score_trending', 'score_new_hot', 'score_best', 'model_version', 'computed_at']
                );

                $total += count($upserts);
            });

        return $total;
    }

    /**
     * Compute the three list-type scores (trending, new_hot, best)
     * using V2 engagement formula that includes shares and comments.
     */
    public function computeListScores(object $row): array
    {
        $cfg = config('ranking');
        $w   = $this->weights();

        // V2 engagement base (includes shares + comments)
        $E = ($w['views']      * log(1 + (float) ($row->views_7d ?? 0)))
           + ($w['downloads']  * log(1 + (float) ($row->downloads_7d ?? 0)))
           + ($w['favourites'] * log(1 + (float) ($row->favourites_7d ?? 0)))
           + ($w['comments']   * log(1 + (float) ($row->comments_24h ?? 0) * 7))
           + ($w['shares']     * log(1 + (float) ($row->shares_24h ?? 0) * 7));

        $E_all = ($w['views']      * log(1 + (float) ($row->views_all ?? 0)))
               + ($w['downloads']  * log(1 + (float) ($row->downloads_all ?? 0)))
               + ($w['favourites'] * log(1 + (float) ($row->favourites_all ?? 0)))
               + ($w['comments']   * log(1 + (float) ($row->comments_count ?? 0)))
               + ($w['shares']     * log(1 + (float) ($row->shares_count ?? 0)));

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

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

        $Q = 1.0;
        if ($tagCount > 0) { $Q += (float) ($cfg['quality']['has_tags'] ?? 0.05); }
        if ($hasThumb)      { $Q += (float) ($cfg['quality']['has_thumbnail'] ?? 0.02); }
        $Q += (float) ($cfg['quality']['tag_count_bonus'] ?? 0.01)
            * (min($tagCount, (int) ($cfg['quality']['tag_count_max'] ?? 10))
               / max((float) ($cfg['quality']['tag_count_max'] ?? 10), 1.0));
        if (! $isVisible) { $Q -= (float) ($cfg['quality']['penalty_hidden'] ?? 0.50); }

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

        // Velocity boost for trending
        $velocityBoost = $this->calculateVelocityBoost($row);

        // Anti-spam
        $spamFactor = 1.0;
        $spam = $cfg['spam'] ?? [];
        if (
            (float) ($row->views_24h ?? 0) > (float) ($spam['views_24h_threshold'] ?? 2000)
            && (float) ($row->views_24h ?? 0) > 0
        ) {
            $rF = (float) ($row->favourites_24h ?? 0) / (float) ($row->views_24h ?? 1);
            $rD = (float) ($row->downloads_24h ?? 0)  / (float) ($row->views_24h ?? 1);
            if ($rF < (float) ($spam['fav_ratio_threshold'] ?? 0.002)
                && $rD < (float) ($spam['dl_ratio_threshold'] ?? 0.001)
            ) {
                $spamFactor = (float) ($spam['trending_penalty_factor'] ?? 0.5);
            }
        }

        $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),
        ];
    }

    // ── Signal query ───────────────────────────────────────────────────────

    /**
     * Build the query that selects all artwork signals needed for V2 scoring.
     *
     * Columns returned:
     *   id, user_id, published_at, is_public, is_approved, has_thumbnail,
     *   views_all, downloads_all, favourites_all, comments_count, shares_count,
     *   views_7d, downloads_7d, favourites_7d,
     *   views_24h, downloads_24h, favourites_24h, comments_24h, shares_24h,
     *   tag_count, age_hours
     */
    public function artworkSignalsQuery(): \Illuminate\Database\Query\Builder
    {
        $hasSharesTable   = Schema::hasTable('artwork_shares');
        $hasCommentsTable = 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'),
                // All-time counters
                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'),
                // Comments count (precomputed or subquery)
                DB::raw(
                    $hasCommentsTable
                        ? 'COALESCE(cc.cnt, 0) AS comments_count'
                        : '0 AS comments_count'
                ),
                // Shares count
                DB::raw(
                    $hasSharesTable
                        ? 'COALESCE(sc.cnt, 0) AS shares_count'
                        : '0 AS shares_count'
                ),
                // 7-day windowed
                DB::raw('COALESCE(ast.views_7d, 0)      AS views_7d'),
                DB::raw('COALESCE(ast.downloads_7d, 0)   AS downloads_7d'),
                DB::raw('COALESCE(fav7.cnt, 0)           AS favourites_7d'),
                // 24-hour windowed
                DB::raw('COALESCE(ast.views_24h, 0)      AS views_24h'),
                DB::raw('COALESCE(ast.downloads_24h, 0)  AS downloads_24h'),
                DB::raw('COALESCE(fav1.cnt, 0)           AS favourites_24h'),
                // 24h comments & shares
                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'
                ),
                // Tag count
                DB::raw('COALESCE(tc.tag_count, 0) AS tag_count'),
                // Age in hours
                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');

        // Comments count (all-time)
        if ($hasCommentsTable) {
            $query->leftJoinSub(
                DB::table('artwork_comments')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->whereNull('deleted_at')
                    ->groupBy('artwork_id'),
                'cc',
                'cc.artwork_id', '=', 'a.id'
            );
            // Comments (24h)
            $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'
            );
        }

        // Shares count (all-time)
        if ($hasSharesTable) {
            $query->leftJoinSub(
                DB::table('artwork_shares')
                    ->select('artwork_id', DB::raw('COUNT(*) as cnt'))
                    ->groupBy('artwork_id'),
                'sc',
                'sc.artwork_id', '=', 'a.id'
            );
            // Shares (24h)
            $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;
    }

    // ── Author authority pre-loading ───────────────────────────────────────

    /**
     * Load author authority data for all users who have published artworks.
     * Returns an array keyed by user_id:
     *   [ userId => ['followers' => int, 'fav_received' => int] ]
     */
    private function loadAuthorAuthorityMap(): array
    {
        $map = [];

        // Get all author user IDs with public artworks
        $rows = DB::table('artworks as a')
            ->select([
                'a.user_id',
                DB::raw('COALESCE(us.followers_count, 0) AS followers_count'),
                DB::raw('COALESCE(us.favorites_received_count, 0) AS favourites_received_count'),
            ])
            ->leftJoin('user_statistics as us', 'us.user_id', '=', 'a.user_id')
            ->where('a.is_public', 1)
            ->where('a.is_approved', 1)
            ->whereNull('a.deleted_at')
            ->groupBy('a.user_id', 'us.followers_count', 'us.favorites_received_count')
            ->get();

        foreach ($rows as $row) {
            $map[(int) $row->user_id] = [
                'followers'    => (int) $row->followers_count,
                'fav_received' => (int) $row->favourites_received_count,
            ];
        }

        return $map;
    }

    // ── Config helpers ─────────────────────────────────────────────────────

    private function weights(): array
    {
        return array_merge(
            self::DEFAULT_WEIGHTS,
            (array) config('ranking.v2.weights', [])
        );
    }

    private function velocityWeights(): array
    {
        return array_merge(
            self::DEFAULT_VELOCITY_WEIGHTS,
            (array) config('ranking.v2.velocity_weights', [])
        );
    }
}
