<?php

declare(strict_types=1);

namespace App\Services\Recommendations;

use App\Models\UserInterestProfile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;

final class SessionRecoService
{
    /**
     * @param array<string, mixed> $meta
     */
    public function applyEvent(
        int $userId,
        string $eventType,
        int $artworkId,
        ?int $categoryId,
        string $occurredAt,
        array $meta = []
    ): void {
        if ($userId <= 0 || $artworkId <= 0) {
            return;
        }

        $state = $this->readState($userId);
        $weights = (array) config('discovery.v2.session.event_weights', []);
        $fallbackWeights = (array) config('discovery.weights', []);
        $eventWeight = (float) ($weights[$eventType] ?? $fallbackWeights[$eventType] ?? 1.0);
        $timestamp = strtotime($occurredAt) ?: time();

        $this->upsertSignal($state['signals'], 'artwork:' . $artworkId, $eventWeight, $timestamp);
        if ($categoryId !== null && $categoryId > 0) {
            $this->upsertSignal($state['signals'], 'category:' . $categoryId, $eventWeight, $timestamp);
        }

        foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
            $this->upsertSignal($state['signals'], 'tag:' . $tagSlug, $eventWeight, $timestamp);
        }

        $creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
        if ($creatorId > 0) {
            $this->upsertSignal($state['signals'], 'creator:' . $creatorId, $eventWeight, $timestamp);
        }

        $state['recent_artwork_ids'] = $this->prependUnique($state['recent_artwork_ids'], $artworkId);
        if ($creatorId > 0) {
            $state['recent_creator_ids'] = $this->prependUnique($state['recent_creator_ids'], $creatorId);
        }

        foreach ($this->tagSlugsForArtwork($artworkId) as $tagSlug) {
            $state['recent_tag_slugs'] = $this->prependUnique($state['recent_tag_slugs'], $tagSlug);
        }

        if (in_array($eventType, ['view', 'click', 'favorite', 'download', 'dwell', 'scroll'], true)) {
            $state['seen_artwork_ids'] = $this->prependUnique($state['seen_artwork_ids'], $artworkId, 200);
        }

        $state['updated_at'] = $timestamp;

        $this->writeState($userId, $state);
    }

    /**
     * @return array{
     *   merged_scores: array<string, float>,
     *   session_scores: array<string, float>,
     *   long_term_scores: array<string, float>,
     *   recent_artwork_ids: array<int, int>,
     *   recent_creator_ids: array<int, int>,
     *   recent_tag_slugs: array<int, string>,
     *   seen_artwork_ids: array<int, int>
     * }
     */
    public function mergedProfile(int $userId, string $algoVersion): array
    {
        $profileVersion = (string) config('discovery.profile_version', 'profile-v1');

        $profile = UserInterestProfile::query()
            ->where('user_id', $userId)
            ->where('profile_version', $profileVersion)
            ->where('algo_version', $algoVersion)
            ->first();

        $longTermScores = $this->normalizeScores((array) ($profile?->normalized_scores_json ?? []));
        $state = $this->readState($userId);
        $sessionScores = $this->materializeSessionScores($state['signals']);
        $multiplier = max(0.0, (float) config('discovery.v2.session.merge_multiplier', 1.35));

        $merged = $longTermScores;
        foreach ($sessionScores as $key => $score) {
            $merged[$key] = (float) ($merged[$key] ?? 0.0) + ($score * $multiplier);
        }

        return [
            'merged_scores' => $this->normalizeScores($merged),
            'session_scores' => $sessionScores,
            'long_term_scores' => $longTermScores,
            'recent_artwork_ids' => array_values(array_map('intval', $state['recent_artwork_ids'])),
            'recent_creator_ids' => array_values(array_map('intval', $state['recent_creator_ids'])),
            'recent_tag_slugs' => array_values(array_map('strval', $state['recent_tag_slugs'])),
            'seen_artwork_ids' => array_values(array_map('intval', $state['seen_artwork_ids'])),
        ];
    }

    /**
     * @return array<int, int>
     */
    public function seenArtworkIds(int $userId): array
    {
        $state = $this->readState($userId);

        return array_values(array_map('intval', $state['seen_artwork_ids']));
    }

    /**
     * @return array<string, mixed>
     */
    private function readState(int $userId): array
    {
        $key = $this->redisKey($userId);

        try {
            $raw = Redis::get($key);
        } catch (\Throwable $e) {
            Log::warning('SessionRecoService read failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
            return $this->emptyState();
        }

        if (! is_string($raw) || $raw === '') {
            return $this->emptyState();
        }

        $decoded = json_decode($raw, true);

        return is_array($decoded) ? array_merge($this->emptyState(), $decoded) : $this->emptyState();
    }

    /**
     * @param array<string, mixed> $state
     */
    private function writeState(int $userId, array $state): void
    {
        $key = $this->redisKey($userId);
        $ttlSeconds = max(60, (int) config('discovery.v2.session.ttl_seconds', 14400));

        try {
            Redis::setex($key, $ttlSeconds, (string) json_encode($state, JSON_UNESCAPED_SLASHES));
        } catch (\Throwable $e) {
            Log::warning('SessionRecoService write failed', ['user_id' => $userId, 'error' => $e->getMessage()]);
        }
    }

    /**
     * @param array<string, mixed> $signals
     * @return array<string, float>
     */
    private function materializeSessionScores(array $signals): array
    {
        $halfLifeHours = max(0.1, (float) config('discovery.v2.session.half_life_hours', 8));
        $now = time();
        $scores = [];

        foreach ($signals as $key => $signal) {
            if (! is_array($signal)) {
                continue;
            }

            $score = (float) Arr::get($signal, 'score', 0.0);
            $updatedAt = (int) Arr::get($signal, 'updated_at', $now);
            $hoursElapsed = max(0.0, ($now - $updatedAt) / 3600);
            $decay = exp(-log(2) * ($hoursElapsed / $halfLifeHours));
            $decayedScore = $score * $decay;

            if ($decayedScore > 0.000001) {
                $scores[(string) $key] = $decayedScore;
            }
        }

        return $this->normalizeScores($scores);
    }

    /**
     * @param array<string, mixed> $signals
     */
    private function upsertSignal(array &$signals, string $key, float $weight, int $timestamp): void
    {
        $maxItems = max(20, (int) config('discovery.v2.session.max_items', 120));
        $current = (array) ($signals[$key] ?? []);

        $signals[$key] = [
            'score' => (float) ($current['score'] ?? 0.0) + $weight,
            'updated_at' => $timestamp,
        ];

        if (count($signals) <= $maxItems) {
            return;
        }

        uasort($signals, static fn (array $left, array $right): int => ((int) ($right['updated_at'] ?? 0)) <=> ((int) ($left['updated_at'] ?? 0)));
        $signals = array_slice($signals, 0, $maxItems, true);
    }

    /**
     * @param array<int, int|string> $items
     * @return array<int, int|string>
     */
    private function prependUnique(array $items, int|string $value, int $maxItems = 40): array
    {
        $items = array_values(array_filter($items, static fn (mixed $item): bool => (string) $item !== (string) $value));
        array_unshift($items, $value);

        return array_slice($items, 0, $maxItems);
    }

    /**
     * @return array<string, mixed>
     */
    private function emptyState(): array
    {
        return [
            'signals' => [],
            'recent_artwork_ids' => [],
            'recent_creator_ids' => [],
            'recent_tag_slugs' => [],
            'seen_artwork_ids' => [],
            'updated_at' => null,
        ];
    }

    private function redisKey(int $userId): string
    {
        return 'session_reco:' . $userId;
    }

    /**
     * @param array<string, mixed> $scores
     * @return array<string, float>
     */
    private function normalizeScores(array $scores): array
    {
        $typed = [];
        foreach ($scores as $key => $score) {
            if (is_numeric($score) && (float) $score > 0.0) {
                $typed[(string) $key] = (float) $score;
            }
        }

        $sum = array_sum($typed);
        if ($sum <= 0.0) {
            return [];
        }

        foreach ($typed as $key => $score) {
            $typed[$key] = $score / $sum;
        }

        return $typed;
    }

    /**
     * @return array<int, string>
     */
    private function tagSlugsForArtwork(int $artworkId): array
    {
        return DB::table('artwork_tag')
            ->join('tags', 'tags.id', '=', 'artwork_tag.tag_id')
            ->where('artwork_tag.artwork_id', $artworkId)
            ->pluck('tags.slug')
            ->map(static fn (mixed $slug): string => (string) $slug)
            ->values()
            ->all();
    }
}
