<?php

namespace App\Services;

use App\Events\UserUnsubscribedFromPodcast;
use App\Exceptions\FailedToParsePodcastFeedException;
use App\Exceptions\UserAlreadySubscribedToPodcastException;
use App\Helpers\Uuid;
use App\Models\Podcast;
use App\Models\PodcastUserPivot;
use App\Models\Song as Episode;
use App\Models\User;
use App\Repositories\PodcastRepository;
use App\Repositories\SongRepository;
use Carbon\Carbon;
use GuzzleHttp\Client;
use GuzzleHttp\RedirectMiddleware;
use GuzzleHttp\RequestOptions;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use PhanAn\Poddle\Poddle;
use PhanAn\Poddle\Values\Episode as EpisodeValue;
use PhanAn\Poddle\Values\EpisodeCollection;
use Psr\Http\Client\ClientInterface;
use Throwable;
use Webmozart\Assert\Assert;

class PodcastService
{
    public function __construct(
        private readonly PodcastRepository $podcastRepository,
        private readonly SongRepository $songRepository,
        private ?ClientInterface $client = null,
    ) {
    }

    public function addPodcast(string $url, User $user): Podcast
    {
        // Since downloading and parsing a feed can be time-consuming, try setting the execution time to 5 minutes
        @ini_set('max_execution_time', 300);

        $podcast = $this->podcastRepository->findOneByUrl($url);

        if ($podcast) {
            if ($this->isPodcastObsolete($podcast)) {
                $this->refreshPodcast($podcast);
            }

            $this->subscribeUserToPodcast($user, $podcast);

            return $podcast;
        }

        try {
            $parser = $this->createParser($url);
            $channel = $parser->getChannel();

            return DB::transaction(function () use ($url, $podcast, $parser, $channel, $user) {
                /** @var Podcast $podcast */
                $podcast = Podcast::query()->create([
                    'url' => $url,
                    'title' => $channel->title,
                    'description' => $channel->description,
                    'author' => $channel->metadata->author,
                    'link' => $channel->link,
                    'language' => $channel->language,
                    'explicit' => $channel->explicit,
                    'image' => $channel->image,
                    'categories' => $channel->categories,
                    'metadata' => $channel->metadata,
                    'added_by' => $user->id,
                    'last_synced_at' => now(),
                ]);

                $this->synchronizeEpisodes($podcast, $parser->getEpisodes(true));
                $this->subscribeUserToPodcast($user, $podcast);

                return $podcast;
            });
        } catch (UserAlreadySubscribedToPodcastException $exception) {
            throw $exception;
        } catch (Throwable $exception) {
            Log::error($exception);
            throw FailedToParsePodcastFeedException::create($url, $exception);
        }
    }

    public function refreshPodcast(Podcast $podcast): Podcast
    {
        $parser = $this->createParser($podcast->url);
        $channel = $parser->getChannel();

        $pubDate = $parser->xmlReader->value('rss.channel.pubDate')->first()
            ?? $parser->xmlReader->value('rss.channel.lastBuildDate')->first();

        if ($pubDate && Carbon::createFromFormat(Carbon::RFC1123, $pubDate)?->isBefore($podcast->last_synced_at)) {
            // The pubDate/lastBuildDate value indicates that there's been no new content since the last check.
            // We'll simply return the podcast.
            return $podcast;
        }

        $this->synchronizeEpisodes($podcast, $parser->getEpisodes(true));

        $podcast->update([
            'title' => $channel->title,
            'description' => $channel->description,
            'author' => $channel->metadata->author,
            'link' => $channel->link,
            'language' => $channel->language,
            'explicit' => $channel->explicit,
            'image' => $channel->image,
            'categories' => $channel->categories,
            'metadata' => $channel->metadata,
            'last_synced_at' => now(),
        ]);

        return $podcast->refresh();
    }

    private function synchronizeEpisodes(Podcast $podcast, EpisodeCollection $episodeCollection): void
    {
        $existingEpisodeGuids = $this->songRepository->getEpisodeGuidsByPodcast($podcast);
        $records = [];
        $ids = [];

        /** @var EpisodeValue $episodeValue */
        foreach ($episodeCollection as $episodeValue) {
            if (!in_array($episodeValue->guid->value, $existingEpisodeGuids, true)) {
                $id = Uuid::generate();
                $ids[] = $id;
                $records[] = [
                    'id' => $id,
                    'podcast_id' => $podcast->id,
                    'title' => $episodeValue->title,
                    'lyrics' => '',
                    'path' => $episodeValue->enclosure->url,
                    'created_at' => $episodeValue->metadata->pubDate ?: now(),
                    'updated_at' => $episodeValue->metadata->pubDate ?: now(),
                    'episode_metadata' => $episodeValue->metadata->toJson(),
                    'episode_guid' => $episodeValue->guid,
                    'length' => $episodeValue->metadata->duration ?? 0,
                    'mtime' => time(),
                    'is_public' => true,
                ];
            }
        }

        // We use insert() instead of $podcast->episodes()->createMany() for better performance,
        // as the latter would trigger a separate query for each episode.
        Episode::query()->insert($records);

        // Since insert() doesn't trigger model events, Scout operations will not be called.
        // We have to manually update the search index.
        Episode::query()->whereIn('id', $ids)->searchable(); // @phpstan-ignore-line
    }

    private function subscribeUserToPodcast(User $user, Podcast $podcast): void
    {
        $user->subscribeToPodcast($podcast);

        // Refreshing so that $podcast->subscribers are updated
        $podcast->refresh();
    }

    public function updateEpisodeProgress(User $user, Episode $episode, int $position): void
    {
        Assert::true($user->subscribedToPodcast($episode->podcast));

        /** @var PodcastUserPivot $subscription */
        $subscription = $episode->podcast->subscribers->sole('id', $user->id)->pivot;

        $state = $subscription->state->toArray();
        $state['current_episode'] = $episode->id;
        $state['progresses'][$episode->id] = $position;

        $subscription->update(['state' => $state]);
    }

    public function unsubscribeUserFromPodcast(User $user, Podcast $podcast): void
    {
        $user->unsubscribeFromPodcast($podcast);
        event(new UserUnsubscribedFromPodcast($user, $podcast));
    }

    public function isPodcastObsolete(Podcast $podcast): bool
    {
        if (abs($podcast->last_synced_at->diffInHours(now())) < 12) {
            // If we have recently synchronized the podcast, consider it "fresh"
            return false;
        }

        try {
            $lastModified = Http::head($podcast->url)->header('Last-Modified');

            return $lastModified
                && Carbon::createFromFormat(Carbon::RFC1123, $lastModified)->isAfter($podcast->last_synced_at);
        } catch (Throwable) {
            return true;
        }
    }

    /**
     * Get a directly streamable (CORS-friendly) URL by following redirects if necessary.
     */
    public function getStreamableUrl(string|Episode $url, ?Client $client = null, string $method = 'OPTIONS'): ?string
    {
        $url = $url instanceof Episode ? $url->path : $url;
        $client ??= new Client();

        try {
            $response = $client->request($method, $url, [
                RequestOptions::HEADERS => [
                    'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15', // @phpcs-ignore-line
                    'Origin' => '*',
                ],
                RequestOptions::HTTP_ERRORS => false,
                RequestOptions::ALLOW_REDIRECTS => ['track_redirects' => true],
            ]);

            $redirects = Arr::wrap($response->getHeader(RedirectMiddleware::HISTORY_HEADER));

            // Sometimes the podcast server disallows OPTIONS requests. We'll try again with a HEAD request.
            if ($response->getStatusCode() >= 400 && $response->getStatusCode() < 500 && $method !== 'HEAD') {
                return $this->getStreamableUrl($url, $client, 'HEAD');
            }

            if (in_array('*', Arr::wrap($response->getHeader('Access-Control-Allow-Origin')), true)) {
                // If there were redirects, the last one is the final URL.
                return $redirects ? Arr::last($redirects) : $url;
            }

            return null;
        } catch (Throwable) {
            return null;
        }
    }

    public function deletePodcast(Podcast $podcast): void
    {
        $podcast->delete();
    }

    private function createParser(string $url): Poddle
    {
        return Poddle::fromUrl($url, 5 * 60, $this->client);
    }
}