<?php

namespace App\Tmdb;

use Aimeos\Map;
use App\Base\Enum\MediaType;
use App\Base\Util\ImdbMatcher;
use App\Tmdb\Dto\TmdbEpisodeDto;
use App\Tmdb\Dto\WatchProviderDto;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Serializer\SerializerInterface;
use Tmdb\Api\Find;
use Tmdb\Client;
use Tmdb\Event\BeforeRequestEvent;
use Tmdb\Event\Listener\Psr6CachedRequestListener;
use Tmdb\Event\Listener\Request\AcceptJsonRequestListener;
use Tmdb\Event\Listener\Request\ApiTokenRequestListener;
use Tmdb\Event\Listener\Request\ContentTypeJsonRequestListener;
use Tmdb\Event\Listener\Request\UserAgentRequestListener;
use Tmdb\Event\RequestEvent;
use Tmdb\Repository\DiscoverRepository;
use Tmdb\Repository\MovieRepository;
use Tmdb\Repository\SearchRepository;
use Tmdb\Repository\TvEpisodeRepository;
use Tmdb\Repository\TvRepository;
use Tmdb\Repository\TvSeasonRepository;
use Tmdb\Token\Api\ApiToken;
use Tmdb\Token\Api\BearerToken;

class TmdbClient
{
    const POSTER_IMG_PATH = "https://image.tmdb.org/t/p/w500";
    const APPEND_TO_RESPONSE = 'external_ids,credits';

    protected Client $client;
    protected MovieRepository $movieRepository;
    protected TvRepository $tvRepository;
    protected TvSeasonRepository $tvSeasonRepository;
    protected TvEpisodeRepository $tvEpisodeRepository;
    protected SearchRepository $searchRepository;

    protected DiscoverRepository $discoverRepository;

    protected array $mediaTypeMap = [
        MediaType::Movie->value => MediaType::Movie->value,
        MediaType::TvShow->value => MediaType::TvShow->value,
        MediaType::TvEpisode->value => MediaType::TvEpisode->value,
        'movie' => 'movies',
        'tv' => 'tvshows',
    ];

    protected $repos = [];

    private ?string $originalLanguage = 'en';

    public function __construct(
        private readonly SerializerInterface $serializer,
        private readonly CacheItemPoolInterface $cache,
        private readonly EventDispatcherInterface $eventDispatcher,
        private readonly LoggerInterface $logger,
        #[Autowire(env: 'TMDB_API')] string $apiKey,
        #[Autowire(env: 'TMDB_ORIGINAL_LANGUAGE')] ?string $originalLanguage = null,
    ) {
        $this->client = new Client(
            [
                /** @var ApiToken|BearerToken */
                'api_token' => new BearerToken($apiKey),
                'secure' => true,
                'base_uri' => Client::TMDB_URI,
                'event_dispatcher' => [
                    'adapter' => $this->eventDispatcher,
                ],
                // We make use of PSR-17 and PSR-18 auto discovery to automatically guess these, but preferably set these explicitly.
                'http' => [
                    'client' => null,
                    'request_factory' => null,
                    'response_factory' => null,
                    'stream_factory' => null,
                    'uri_factory' => null,
                ],
                'hydration' => [
                    'event_listener_handles_hydration' => false,
                    'only_for_specified_models' => []
                ],
            ]
        );

        /**
         * Required event listeners and events to be registered with the PSR-14 Event Dispatcher.
         */
        $requestListener = new Psr6CachedRequestListener(
            $this->client->getHttpClient(),
            $this->eventDispatcher,
            $cache,
            $this->client->getHttpClient()->getPsr17StreamFactory(),
            []
        );
        $this->eventDispatcher->addListener(RequestEvent::class, $requestListener);

        $apiTokenListener = new ApiTokenRequestListener($this->client->getToken());
        $this->eventDispatcher->addListener(BeforeRequestEvent::class, $apiTokenListener);

        $acceptJsonListener = new AcceptJsonRequestListener();
        $this->eventDispatcher->addListener(BeforeRequestEvent::class, $acceptJsonListener);

        $jsonContentTypeListener = new ContentTypeJsonRequestListener();
        $this->eventDispatcher->addListener(BeforeRequestEvent::class, $jsonContentTypeListener);

        $userAgentListener = new UserAgentRequestListener();
        $this->eventDispatcher->addListener(BeforeRequestEvent::class, $userAgentListener);

        $this->movieRepository = new MovieRepository($this->client);
        $this->tvRepository = new TvRepository($this->client);
        $this->tvSeasonRepository = new TvSeasonRepository($this->client);
        $this->tvEpisodeRepository = new TvEpisodeRepository($this->client);
        $this->searchRepository = new SearchRepository($this->client);
        $this->discoverRepository = new DiscoverRepository($this->client);

        $this->repos = [
            MediaType::Movie->value => $this->movieRepository,
            MediaType::TvShow->value => $this->tvRepository,
            MediaType::TvEpisode->value => $this->tvEpisodeRepository,
        ];
        $this->originalLanguage = $originalLanguage;
    }

    public function search(string $term): TmdbResult|Map
    {
        if (ImdbMatcher::isMatch($term)) {
            $handlers = [
                'movie' => 'movieDetails',
                'tvshow' => 'tvshowDetails',
            ];
            $data = $this->findByImdbId($term);
            $handler = $handlers[$data['media_type']];
            return $this->$handler($term);
        }
        $results = $this->searchRepository->getApi()->searchMulti($term);
        return $this->parseListOfResults($results);
    }

    public function discoverMovies(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
    {
        if (!empty($genres) && $genres[0] instanceof TmdbMovieGenre) {
            $genres = array_map(fn ($genre) => $genre->value, $genres);
        }
        $results = $this->discoverRepository->getApi()->discoverMovies([
            'page' => $page,
            'page_size' => $pageSize,
            'with_genres' => implode(',', $genres),
            'with_original_language' => $this->originalLanguage,
            'append_to_response' => static::APPEND_TO_RESPONSE,
        ]);
        $results['results'] = Map::from($results['results'])->map(function ($result) {
            $result['media_type'] = MediaType::Movie->value;
            return $result;
        });

        return $this->parseListOfResults(
            $results,
        );
    }

    public function discoverTvshows(array $genres = [], int $page = 1, int $pageSize = 24, array $params = []): TmdbResult|Map
    {
        if (!empty($genres) && $genres[0] instanceof TmdbTvShowGenre) {
            $genres = array_map(fn ($genre) => $genre->value, $genres);
        }
        $results = $this->discoverRepository->getApi()->discoverTv([
            'page' => $page,
            'page_size' => $pageSize,
            'with_genres' => implode(',', $genres),
            'with_original_language' => $this->originalLanguage,
            'append_to_response' => static::APPEND_TO_RESPONSE,
        ]);
        $results['results'] = Map::from($results['results'])->map(function ($result) {
            $result['media_type'] = MediaType::TvShow->value;
            return $result;
        });

        return $this->parseListOfResults(
            $results,
        );
    }

    public function movieDetails(string $imdbId): ?TmdbResult
    {
        $tmdbId = $this->findByImdbId($imdbId)['id'];
        return $this->parseResult(
            $this->movieRepository->getApi()->getMovie($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]),
            MediaType::Movie->value,
            $imdbId
        );
    }

    public function tvshowDetails(string $imdbId): ?TmdbResult
    {
        $tmdbId = $this->findByImdbId($imdbId)['id'];
        $media = $this->tvRepository->getApi()->getTvShow($tmdbId, ['append_to_response' => static::APPEND_TO_RESPONSE]);

        $media['seasons'] = Map::from($media['seasons'])->filter(function ($data) {
            return $data['season_number'] !== 0 &&
                strtolower($data['name']) !== 'specials' &&
                $data['episode_count'] > 0;
        })->map(function ($data) use ($media) {
            return $this->tvSeasonDetails($media['id'], $data['season_number'])['episodes'];
        })->rekey(function ($data) use ($media) {
            return $data[1]['season_number'];
        })->toArray();

        return $this->parseResult(
            $media,
            MediaType::TvShow->value,
            $imdbId
        );
    }

    public function tvSeasonDetails(string $tmdbId, int $season): array
    {
        $result = $this->tvSeasonRepository->getApi()->getSeason($tmdbId, $season, ['append_to_response' => static::APPEND_TO_RESPONSE]);
        $result['episodes'] = Map::from($result['episodes'])->map(function ($data) {
            $data['still_path'] = self::POSTER_IMG_PATH . $data['still_path'];
            $data['poster'] = $data['still_path'];
            return $data;
        })->rekey(fn ($data) => $data['episode_number'])->toArray();
        return $result;
    }

    public function tvEpisodeDetails(string $tmdbId, string $showImdbId, int $season, int $episode): TmdbResult|TmdbEpisodeDto|null
    {
        $result = $this->tvEpisodeRepository->getApi()->getEpisode($tmdbId, $season, $episode, ['append_to_response' => static::APPEND_TO_RESPONSE]);
        return $this->parseResult(
            $result,
            MediaType::TvEpisode->value,
            $showImdbId
        );
    }

    public function relatedMedia(string $tmdbId, string $mediaType, int $resultCount = 6): Map
    {
        $results = $this->repos[$mediaType]->getApi()->getRecommendations($tmdbId, ['append_to_response' => 'external_ids']);
        return $this->parseListOfResults(
            $results,
            $resultCount
        );
    }

    public function watchProviders(string $tmdbId, string $mediaType): Map
    {
        $results = $this->repos[$mediaType]->getApi()->getWatchProviders($tmdbId)['results']['US']['flatrate'];
        return Map::from($results)->map(function ($result) {
            return $this->serializer->denormalize($result, WatchProviderDto::class);
        });
    }

    public function popularMovies(int $resultCount = 6): Map
    {
        $results = $this->discoverRepository->getApi()->discoverMovies([
            'with_original_language' => $this->originalLanguage,
            'append_to_response' => 'external_ids,watch/providers',
        ]);
        $results['results'] = Map::from($results['results'])->map(function ($result) {
            $result['media_type'] = MediaType::Movie->value;
            return $result;
        });
        return $this->parseListOfResults(
            $results,
            $resultCount
        );
    }

    public function popularTvShows(int $resultCount = 6): Map
    {
        $results = $this->discoverRepository->getApi()->discoverTv([
            'with_original_language' => $this->originalLanguage,
            'append_to_response' => 'external_ids,watch/providers',
        ]);
        $results['results'] = Map::from($results['results'])->map(function ($result) {
            $result['media_type'] = MediaType::TvShow->value;
            return $result;
        });
        return $this->parseListOfResults(
            $results,
            $resultCount
        );
    }

    private function getExternalIds(int $tmdbId, string $mediaType): ?array
    {
        if (!array_key_exists($mediaType, $this->repos) ||
            !in_array($mediaType, [MediaType::Movie->value, MediaType::TvShow->value])) {
            return [];
        }
        try {
            return $this->repos[$mediaType]->getApi()->getExternalIds($tmdbId);
        } catch (\Throwable $throwable) {
            $this->logger->warning("[TmdbClient] Error getting external ids for $tmdbId: " . $throwable->getMessage());
            return [];
        }
    }

    private function findByImdbId(string $imdbId): array
    {
        $finder = new Find($this->client);
        $result = $finder->findBy($imdbId, ['external_source' => 'imdb_id']);

        if (count($result['movie_results']) > 0) {
            return $result['movie_results'][0];
        } elseif (count($result['tv_results']) > 0) {
            return $result['tv_results'][0];
        } elseif (count($result['tv_episode_results']) > 0) {
            return $result['tv_episode_results'][0];
        }

        throw new \Exception("No results found for $imdbId");
    }

    private function parseResult(array $data, string $mediaType, string $imdbId): TmdbResult|TmdbEpisodeDto
    {
        if (!array_key_exists('external_ids', $data)) {
            $data['external_ids'] = ['imdb_id' => $imdbId];
        }
        return $this->serializer->denormalize($data, TmdbResult::class, context: ['media_type' => $mediaType]);
    }

    private function parseListOfResults(array $data, ?int $resultCount = null): Map
    {
        $results = Map::from($data['results'])->filter(function ($result) {
            return array_key_exists('media_type', $result) &&
                in_array($result['media_type'], array_keys($this->mediaTypeMap));
        })->map(function ($result) {
            $result['external_ids'] = $this->getExternalIds($result['id'], $this->mediaTypeMap[$result['media_type']]);
            return $result;
        })->filter(function ($result) {
            return array_key_exists('id', $result) &&
                array_key_exists('imdb_id', $result['external_ids']) &&
                $result['external_ids']['imdb_id'] !== null &&
                $result['external_ids']['imdb_id'] !== "";
        })->map(function ($result) {
            return $this->serializer->denormalize($result, TmdbResult::class, context: ['media_type' => $this->mediaTypeMap[$result['media_type']]]);
        });

        if (null !== $resultCount) {
            $results = $results->slice(0, $resultCount);
        }

        return $results;
    }
}
