package autodownloader

import (
	"fmt"
	"seanime/internal/api/anilist"
	"seanime/internal/api/metadata"
	"seanime/internal/database/db"
	"seanime/internal/database/db_bridge"
	"seanime/internal/database/models"
	debrid_client "seanime/internal/debrid/client"
	"seanime/internal/debrid/debrid"
	"seanime/internal/events"
	hibiketorrent "seanime/internal/extension/hibike/torrent"
	"seanime/internal/hook"
	"seanime/internal/library/anime"
	"seanime/internal/notifier"
	"seanime/internal/torrent_clients/torrent_client"
	"seanime/internal/torrents/torrent"
	"seanime/internal/util"
	"seanime/internal/util/comparison"
	"sort"
	"strings"
	"sync"
	"time"

	"github.com/5rahim/habari"
	"github.com/adrg/strutil/metrics"
	"github.com/rs/zerolog"
	"github.com/samber/lo"
	"github.com/samber/mo"
	"github.com/sourcegraph/conc/pool"
)

const (
	ComparisonThreshold = 0.8
)

type (
	AutoDownloader struct {
		logger                  *zerolog.Logger
		torrentClientRepository *torrent_client.Repository
		torrentRepository       *torrent.Repository
		debridClientRepository  *debrid_client.Repository
		database                *db.Database
		animeCollection         mo.Option[*anilist.AnimeCollection]
		wsEventManager          events.WSEventManagerInterface
		settings                *models.AutoDownloaderSettings
		metadataProvider        metadata.Provider
		settingsUpdatedCh       chan struct{}
		stopCh                  chan struct{}
		startCh                 chan struct{}
		debugTrace              bool
		mu                      sync.Mutex
		isOffline               *bool
	}

	NewAutoDownloaderOptions struct {
		Logger                  *zerolog.Logger
		TorrentClientRepository *torrent_client.Repository
		TorrentRepository       *torrent.Repository
		WSEventManager          events.WSEventManagerInterface
		Database                *db.Database
		MetadataProvider        metadata.Provider
		DebridClientRepository  *debrid_client.Repository
		IsOffline               *bool
	}

	tmpTorrentToDownload struct {
		torrent *NormalizedTorrent
		episode int
	}
)

func New(opts *NewAutoDownloaderOptions) *AutoDownloader {
	return &AutoDownloader{
		logger:                  opts.Logger,
		torrentClientRepository: opts.TorrentClientRepository,
		torrentRepository:       opts.TorrentRepository,
		database:                opts.Database,
		wsEventManager:          opts.WSEventManager,
		animeCollection:         mo.None[*anilist.AnimeCollection](),
		metadataProvider:        opts.MetadataProvider,
		debridClientRepository:  opts.DebridClientRepository,
		settings: &models.AutoDownloaderSettings{
			Provider:              torrent.ProviderAnimeTosho, // Default provider, will be updated after the settings are fetched
			Interval:              20,
			Enabled:               false,
			DownloadAutomatically: false,
			EnableEnhancedQueries: false,
		},
		settingsUpdatedCh: make(chan struct{}, 1),
		stopCh:            make(chan struct{}, 1),
		startCh:           make(chan struct{}, 1),
		debugTrace:        true,
		mu:                sync.Mutex{},
		isOffline:         opts.IsOffline,
	}
}

// SetSettings should be called after the settings are fetched and updated from the database.
// If the AutoDownloader is not active, it will start it if the settings are enabled.
// If the AutoDownloader is active, it will stop it if the settings are disabled.
func (ad *AutoDownloader) SetSettings(settings *models.AutoDownloaderSettings, provider string) {
	defer util.HandlePanicInModuleThen("autodownloader/SetSettings", func() {})

	event := &AutoDownloaderSettingsUpdatedEvent{
		Settings: settings,
	}
	_ = hook.GlobalHookManager.OnAutoDownloaderSettingsUpdated().Trigger(event)
	settings = event.Settings

	if ad == nil {
		return
	}
	go func() {
		ad.mu.Lock()
		defer ad.mu.Unlock()
		ad.settings = settings
		// Update the provider if it's provided
		if provider != "" {
			ad.settings.Provider = provider
		}
		ad.settingsUpdatedCh <- struct{}{} // Notify that the settings have been updated
		if ad.settings.Enabled {
			ad.startCh <- struct{}{} // Start the auto downloader
		} else if !ad.settings.Enabled {
			ad.stopCh <- struct{}{} // Stop the auto downloader
		}
	}()
}

func (ad *AutoDownloader) SetAnimeCollection(ac *anilist.AnimeCollection) {
	ad.animeCollection = mo.Some(ac)
}

func (ad *AutoDownloader) SetTorrentClientRepository(repo *torrent_client.Repository) {
	defer util.HandlePanicInModuleThen("autodownloader/SetTorrentClientRepository", func() {})

	if ad == nil {
		return
	}
	ad.torrentClientRepository = repo
}

// Start will start the auto downloader in a goroutine
func (ad *AutoDownloader) Start() {
	defer util.HandlePanicInModuleThen("autodownloader/Start", func() {})

	if ad == nil {
		return
	}
	go func() {
		ad.mu.Lock()
		if ad.settings.Enabled {
			started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
			if !started {
				ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running for the Auto Downloader to work.")
				ad.mu.Unlock()
				return
			}
		}
		ad.mu.Unlock()

		// Start the auto downloader
		ad.start()
	}()
}

func (ad *AutoDownloader) Run() {
	defer util.HandlePanicInModuleThen("autodownloader/Run", func() {})

	if ad == nil {
		return
	}
	go func() {
		ad.mu.Lock()
		defer ad.mu.Unlock()
		ad.startCh <- struct{}{}
		ad.logger.Trace().Msg("autodownloader: Received start signal")
	}()
}

// CleanUpDownloadedItems will clean up downloaded items from the database.
// This should be run after a scan is completed.
func (ad *AutoDownloader) CleanUpDownloadedItems() {
	defer util.HandlePanicInModuleThen("autodownloader/CleanUpDownloadedItems", func() {})

	if ad == nil {
		return
	}
	ad.mu.Lock()
	defer ad.mu.Unlock()
	err := ad.database.DeleteDownloadedAutoDownloaderItems()
	if err != nil {
		return
	}
}

func (ad *AutoDownloader) start() {
	defer util.HandlePanicInModuleThen("autodownloader/start", func() {})

	if ad.settings.Enabled {
		ad.logger.Info().Msg("autodownloader: Module started")
	}

	for {
		interval := 20
		// Use the user-defined interval if it's greater or equal to 15
		if ad.settings != nil && ad.settings.Interval > 0 && ad.settings.Interval >= 15 {
			interval = ad.settings.Interval
		}
		ticker := time.NewTicker(time.Duration(interval) * time.Minute)
		select {
		case <-ad.settingsUpdatedCh:
			break // Restart the loop
		case <-ad.stopCh:

		case <-ad.startCh:
			if ad.settings.Enabled {
				ad.logger.Debug().Msg("autodownloader: Auto Downloader started")
				ad.checkForNewEpisodes()
			}
		case <-ticker.C:
			if ad.settings.Enabled {
				ad.checkForNewEpisodes()
			}
		}
		ticker.Stop()
	}

}

func (ad *AutoDownloader) checkForNewEpisodes() {
	defer util.HandlePanicInModuleThen("autodownloader/checkForNewEpisodes", func() {})

	if ad.isOffline != nil && *ad.isOffline {
		ad.logger.Debug().Msg("autodownloader: Skipping check for new episodes. AutoDownloader is in offline mode.")
		return
	}

	ad.mu.Lock()
	if ad == nil || ad.torrentRepository == nil || !ad.settings.Enabled || ad.settings.Provider == "" || ad.settings.Provider == torrent.ProviderNone {
		ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. AutoDownloader is not enabled or provider is not set.")
		ad.mu.Unlock()
		return
	}

	// DEVNOTE: [checkForNewEpisodes] is called on startup, when the default anime provider extension has not yet been loaded.
	providerExt, found := ad.torrentRepository.GetDefaultAnimeProviderExtension()
	if !found {
		//ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. Default provider not found.")
		ad.mu.Unlock()
		return
	}
	if providerExt.GetProvider().GetSettings().Type != hibiketorrent.AnimeProviderTypeMain {
		ad.logger.Warn().Msgf("autodownloader: Could not check for new episodes. Provider '%s' cannot be used for auto downloading.", providerExt.GetName())
		ad.mu.Unlock()
		return
	}
	ad.mu.Unlock()

	torrents := make([]*NormalizedTorrent, 0)

	// Get rules from the database
	rules, err := db_bridge.GetAutoDownloaderRules(ad.database)
	if err != nil {
		ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch rules from the database")
		return
	}

	// Filter out disabled rules
	_filteredRules := make([]*anime.AutoDownloaderRule, 0)
	for _, rule := range rules {
		if rule.Enabled {
			_filteredRules = append(_filteredRules, rule)
		}
	}
	rules = _filteredRules

	// Event
	event := &AutoDownloaderRunStartedEvent{
		Rules: rules,
	}
	_ = hook.GlobalHookManager.OnAutoDownloaderRunStarted().Trigger(event)
	rules = event.Rules

	// Default prevented, return
	if event.DefaultPrevented {
		return
	}

	// If there are no rules, return
	if len(rules) == 0 {
		ad.logger.Debug().Msg("autodownloader: No rules found")
		return
	}

	// Get local files from the database
	lfs, _, err := db_bridge.GetLocalFiles(ad.database)
	if err != nil {
		ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch local files from the database")
		return
	}
	// Create a LocalFileWrapper
	lfWrapper := anime.NewLocalFileWrapper(lfs)

	// Get the latest torrents
	torrents, err = ad.getLatestTorrents(rules)
	if err != nil {
		ad.logger.Error().Err(err).Msg("autodownloader: Failed to get latest torrents")
		return
	}

	// Event
	fetchedEvent := &AutoDownloaderTorrentsFetchedEvent{
		Torrents: torrents,
	}
	_ = hook.GlobalHookManager.OnAutoDownloaderTorrentsFetched().Trigger(fetchedEvent)
	torrents = fetchedEvent.Torrents

	// // Try to start the torrent client if it's not running
	// if ad.torrentClientRepository != nil {
	// 	started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
	// 	if !started {
	// 		ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running.")
	// 	}
	// }

	// Get existing torrents
	existingTorrents := make([]*torrent_client.Torrent, 0)
	if ad.torrentClientRepository != nil {
		existingTorrents, err = ad.torrentClientRepository.GetList()
		if err != nil {
			existingTorrents = make([]*torrent_client.Torrent, 0)
		}
	}

	downloaded := 0
	mu := sync.Mutex{}

	// Going through each rule
	p := pool.New()
	for _, rule := range rules {
		rule := rule
		p.Go(func() {
			if !rule.Enabled {
				return // Skip rule
			}
			listEntry, found := ad.getRuleListEntry(rule)
			// If the media is not found, skip the rule
			if !found {
				return // Skip rule
			}

			// DEVNOTE: This is bad, do not skip anime that are not releasing because dubs are delayed
			// If the media is not releasing AND has more than one episode, skip the rule
			// This is to avoid skipping movies and single-episode OVAs
			//if *listEntry.GetMedia().GetStatus() != anilist.MediaStatusReleasing && listEntry.GetMedia().GetCurrentEpisodeCount() > 1 {
			//	return // Skip rule
			//}

			localEntry, _ := lfWrapper.GetLocalEntryById(listEntry.GetMedia().GetID())

			// +---------------------+
			// |    Existing Item    |
			// +---------------------+
			items, err := ad.database.GetAutoDownloaderItemByMediaId(listEntry.GetMedia().GetID())
			if err != nil {
				items = make([]*models.AutoDownloaderItem, 0)
			}

			// Get all torrents that follow the rule
			torrentsToDownload := make([]*tmpTorrentToDownload, 0)
		outer:
			for _, t := range torrents {
				// If the torrent is already added, skip it
				for _, et := range existingTorrents {
					if et.Hash == t.InfoHash {
						continue outer // Skip the torrent
					}
				}

				episode, ok := ad.torrentFollowsRule(t, rule, listEntry, localEntry, items)
				event := &AutoDownloaderMatchVerifiedEvent{
					Torrent:    t,
					Rule:       rule,
					ListEntry:  listEntry,
					LocalEntry: localEntry,
					Episode:    episode,
					MatchFound: ok,
				}
				_ = hook.GlobalHookManager.OnAutoDownloaderMatchVerified().Trigger(event)
				t = event.Torrent
				rule = event.Rule
				listEntry = event.ListEntry
				localEntry = event.LocalEntry
				episode = event.Episode
				ok = event.MatchFound

				// Default prevented, skip the torrent
				if event.DefaultPrevented {
					continue outer // Skip the torrent
				}

				if ok {
					torrentsToDownload = append(torrentsToDownload, &tmpTorrentToDownload{
						torrent: t,
						episode: episode,
					})
				}
			}

			// Download the torrent if there's only one
			if len(torrentsToDownload) == 1 {
				t := torrentsToDownload[0]
				ok := ad.downloadTorrent(t.torrent, rule, t.episode)
				if ok {
					downloaded++
				}
				return
			}

			// If there's more than one, we will group them by episode and sort them
			// Make a map [episode]torrents
			epMap := make(map[int][]*tmpTorrentToDownload)
			for _, t := range torrentsToDownload {
				if _, ok := epMap[t.episode]; !ok {
					epMap[t.episode] = make([]*tmpTorrentToDownload, 0)
					epMap[t.episode] = append(epMap[t.episode], t)
				} else {
					epMap[t.episode] = append(epMap[t.episode], t)
				}
			}

			// Go through each episode group and download the best torrent (by resolution and seeders)
			for ep, torrents := range epMap {

				// If there's only one torrent for the episode, download it
				if len(torrents) == 1 {
					ok := ad.downloadTorrent(torrents[0].torrent, rule, ep)
					if ok {
						mu.Lock()
						downloaded++
						mu.Unlock()
					}
					continue
				}

				// If there are more than one
				// Sort by resolution
				sort.Slice(torrents, func(i, j int) bool {
					qI := comparison.ExtractResolutionInt(torrents[i].torrent.ParsedData.VideoResolution)
					qJ := comparison.ExtractResolutionInt(torrents[j].torrent.ParsedData.VideoResolution)
					return qI > qJ
				})
				// Sort by seeds
				sort.Slice(torrents, func(i, j int) bool {
					return torrents[i].torrent.Seeders > torrents[j].torrent.Seeders
				})

				ok := ad.downloadTorrent(torrents[0].torrent, rule, ep)
				if ok {
					mu.Lock()
					downloaded++
					mu.Unlock()
				}
			}
		})
	}
	p.Wait()

	if downloaded > 0 {
		if ad.settings.DownloadAutomatically {
			notifier.GlobalNotifier.Notify(
				notifier.AutoDownloader,
				fmt.Sprintf("%d %s %s been downloaded.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")),
			)
		} else {
			notifier.GlobalNotifier.Notify(
				notifier.AutoDownloader,
				fmt.Sprintf("%d %s %s been added to the queue.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")),
			)
		}
	}

}

func (ad *AutoDownloader) torrentFollowsRule(
	t *NormalizedTorrent,
	rule *anime.AutoDownloaderRule,
	listEntry *anilist.AnimeListEntry,
	localEntry *anime.LocalFileWrapperEntry,
	items []*models.AutoDownloaderItem,
) (int, bool) {
	defer util.HandlePanicInModuleThen("autodownloader/torrentFollowsRule", func() {})

	if ok := ad.isReleaseGroupMatch(t.ParsedData.ReleaseGroup, rule); !ok {
		return -1, false
	}

	if ok := ad.isResolutionMatch(t.ParsedData.VideoResolution, rule); !ok {
		return -1, false
	}

	if ok := ad.isTitleMatch(t.ParsedData, t.Name, rule, listEntry); !ok {
		return -1, false
	}

	if ok := ad.isAdditionalTermsMatch(t.Name, rule); !ok {
		return -1, false
	}

	episode, ok := ad.isSeasonAndEpisodeMatch(t.ParsedData, rule, listEntry, localEntry, items)
	if !ok {
		return -1, false
	}

	return episode, true
}

func (ad *AutoDownloader) downloadTorrent(t *NormalizedTorrent, rule *anime.AutoDownloaderRule, episode int) bool {
	defer util.HandlePanicInModuleThen("autodownloader/downloadTorrent", func() {})

	ad.mu.Lock()
	defer ad.mu.Unlock()

	// Double check that the episode hasn't been added while we have the lock
	items, err := ad.database.GetAutoDownloaderItemByMediaId(rule.MediaId)
	if err == nil {
		for _, item := range items {
			if item.Episode == episode {
				return false // Skip, episode was added by another goroutine
			}
		}
	}

	// Event
	beforeEvent := &AutoDownloaderBeforeDownloadTorrentEvent{
		Torrent: t,
		Rule:    rule,
		Items:   items,
	}
	_ = hook.GlobalHookManager.OnAutoDownloaderBeforeDownloadTorrent().Trigger(beforeEvent)
	t = beforeEvent.Torrent
	rule = beforeEvent.Rule
	_ = beforeEvent.Items

	// Default prevented, return
	if beforeEvent.DefaultPrevented {
		return false
	}

	providerExtension, found := ad.torrentRepository.GetDefaultAnimeProviderExtension()
	if !found {
		ad.logger.Warn().Msg("autodownloader: Could not download torrent. Default provider not found")
		return false
	}

	if ad.torrentClientRepository == nil {
		ad.logger.Error().Msg("autodownloader: torrent client not found")
		return false
	}

	useDebrid := false

	if ad.settings.UseDebrid {
		// Check if the debrid provider is enabled
		if !ad.debridClientRepository.HasProvider() || !ad.debridClientRepository.GetSettings().Enabled {
			ad.logger.Error().Msg("autodownloader: Debrid provider not found or not enabled")
			// We return instead of falling back to torrent client
			return false
		}
		useDebrid = true
	}

	// Get torrent magnet
	magnet, err := t.GetMagnet(providerExtension.GetProvider())
	if err != nil {
		ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to get magnet link for torrent")
		return false
	}

	downloaded := false

	if useDebrid {
		//
		// Debrid
		//

		if ad.settings.DownloadAutomatically {
			// Add the torrent to the debrid provider and queue it
			_, err := ad.debridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{
				MagnetLink:   magnet,
				SelectFileId: "all", // RD-only, select all files
			}, rule.Destination, rule.MediaId)
			if err != nil {
				ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid")
				return false
			}
		} else {
			debridProvider, err := ad.debridClientRepository.GetProvider()
			if err != nil {
				ad.logger.Error().Err(err).Msg("autodownloader: Failed to get debrid provider")
				return false
			}

			// Add the torrent to the debrid provider
			_, err = debridProvider.AddTorrent(debrid.AddTorrentOptions{
				MagnetLink:   magnet,
				SelectFileId: "all", // RD-only, select all files
			})
			if err != nil {
				ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid")
				return false
			}
		}

	} else {
		// Pause the torrent when it's added
		if ad.settings.DownloadAutomatically {

			//
			// Torrent client
			//
			started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
			if !started {
				ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to download torrent. torrent client is not running.")
				return false
			}

			// Return if the torrent is already added
			torrentExists := ad.torrentClientRepository.TorrentExists(t.InfoHash)
			if torrentExists {
				//ad.Logger.Debug().Str("name", t.Name).Msg("autodownloader: Torrent already added")
				return false
			}

			ad.logger.Debug().Msgf("autodownloader: Downloading torrent: %s", t.Name)

			// Add the torrent to torrent client
			err := ad.torrentClientRepository.AddMagnets([]string{magnet}, rule.Destination)
			if err != nil {
				ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to torrent client")
				return false
			}

			downloaded = true
		}
	}

	ad.logger.Info().Str("name", t.Name).Msg("autodownloader: Added torrent")
	ad.wsEventManager.SendEvent(events.AutoDownloaderItemAdded, t.Name)

	// Add the torrent to the database
	item := &models.AutoDownloaderItem{
		RuleID:      rule.DbID,
		MediaID:     rule.MediaId,
		Episode:     episode,
		Link:        t.Link,
		Hash:        t.InfoHash,
		Magnet:      magnet,
		TorrentName: t.Name,
		Downloaded:  downloaded,
	}
	_ = ad.database.InsertAutoDownloaderItem(item)

	// Event
	afterEvent := &AutoDownloaderAfterDownloadTorrentEvent{
		Torrent: t,
		Rule:    rule,
	}
	_ = hook.GlobalHookManager.OnAutoDownloaderAfterDownloadTorrent().Trigger(afterEvent)

	return true
}

//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

func (ad *AutoDownloader) isAdditionalTermsMatch(torrentName string, rule *anime.AutoDownloaderRule) (ok bool) {
	defer util.HandlePanicInModuleThen("autodownloader/isAdditionalTermsMatch", func() {
		ok = false
	})

	if len(rule.AdditionalTerms) == 0 {
		return true
	}

	// Go through each additional term
	for _, optionsText := range rule.AdditionalTerms {
		// Split the options by comma
		options := strings.Split(strings.TrimSpace(optionsText), ",")
		// Check if the torrent name contains at least one of the options
		foundOption := false
		for _, option := range options {
			option := strings.TrimSpace(option)
			if strings.Contains(strings.ToLower(torrentName), strings.ToLower(option)) {
				foundOption = true
			}
		}
		// If the torrent name doesn't contain any of the options, return false
		if !foundOption {
			return false
		}
	}

	// If all options are found, return true
	return true
}
func (ad *AutoDownloader) isReleaseGroupMatch(releaseGroup string, rule *anime.AutoDownloaderRule) (ok bool) {
	defer util.HandlePanicInModuleThen("autodownloader/isReleaseGroupMatch", func() {
		ok = false
	})

	if len(rule.ReleaseGroups) == 0 {
		return true
	}
	for _, rg := range rule.ReleaseGroups {
		if strings.ToLower(rg) == strings.ToLower(releaseGroup) {
			return true
		}
	}
	return false
}

// isResolutionMatch
// DEVOTE: Improve this
func (ad *AutoDownloader) isResolutionMatch(quality string, rule *anime.AutoDownloaderRule) (ok bool) {
	defer util.HandlePanicInModuleThen("autodownloader/isResolutionMatch", func() {
		ok = false
	})

	if len(rule.Resolutions) == 0 {
		return true
	}
	if quality == "" {
		return false
	}
	for _, q := range rule.Resolutions {
		qualityWithoutP := strings.TrimSuffix(quality, "p")
		qWithoutP := strings.TrimSuffix(q, "p")
		if quality == q || qualityWithoutP == qWithoutP {
			return true
		}
		if strings.Contains(quality, qWithoutP) { // e.g. 1080 in 1920x1080
			return true
		}
	}
	return false
}

func (ad *AutoDownloader) isTitleMatch(torrentParsedData *habari.Metadata, torrentName string, rule *anime.AutoDownloaderRule, listEntry *anilist.AnimeListEntry) (ok bool) {
	defer util.HandlePanicInModuleThen("autodownloader/isTitleMatch", func() {
		ok = false
	})

	switch rule.TitleComparisonType {
	case anime.AutoDownloaderRuleTitleComparisonContains:
		// +---------------------+
		// |   Title "Contains"  |
		// +---------------------+

		// Check if the torrent name contains the comparison title exactly
		// This will fail for torrent titles that don't contain a season number if the comparison title has a season number
		if strings.Contains(strings.ToLower(torrentParsedData.Title), strings.ToLower(rule.ComparisonTitle)) {
			return true
		}
		if strings.Contains(strings.ToLower(torrentName), strings.ToLower(rule.ComparisonTitle)) {
			return true
		}

	case anime.AutoDownloaderRuleTitleComparisonLikely:
		// +---------------------+
		// |   Title "Likely"    |
		// +---------------------+

		torrentTitle := torrentParsedData.Title
		comparisonTitle := strings.ReplaceAll(strings.ReplaceAll(rule.ComparisonTitle, "[", ""), "]", "")

		// 1. Use comparison title (without season number - if it exists)
		// Remove season number from the torrent title if it exists
		parsedComparisonTitle := habari.Parse(comparisonTitle)
		if parsedComparisonTitle.Title != "" && len(parsedComparisonTitle.SeasonNumber) > 0 {
			_comparisonTitle := parsedComparisonTitle.Title
			if len(parsedComparisonTitle.ReleaseGroup) > 0 {
				_comparisonTitle = fmt.Sprintf("%s %s", parsedComparisonTitle.ReleaseGroup, _comparisonTitle)
				_comparisonTitle = strings.TrimSpace(_comparisonTitle)
			}

			// First, use comparison title, compare without season number
			// e.g. Torrent: "[Seanime] Jujutsu Kaisen 2nd Season - 20 [...].mkv" -> "Jujutsu Kaisen"
			// e.g. Comparison Title: "Jujutsu Kaisen 2nd Season" -> "Jujutsu Kaisen"

			// DEVNOTE: isSeasonAndEpisodeMatch will handle the case where the torrent has a season number

			// Make sure the distance is not too great
			lev := metrics.NewLevenshtein()
			lev.CaseSensitive = false
			res := lev.Distance(torrentTitle, _comparisonTitle)
			if res < 4 {
				return true
			}
		}

		// 2. Use media titles
		// If we're here, it means that either
		// - the comparison title doesn't have a season number
		// - the comparison title (w/o season number) is not similar to the torrent title

		torrentTitleVariations := []*string{&torrentTitle}

		if len(torrentParsedData.SeasonNumber) > 0 {
			season := util.StringToIntMust(torrentParsedData.SeasonNumber[0])
			if season > 1 {
				// If the torrent has a season number, add it to the variations
				torrentTitleVariations = []*string{
					lo.ToPtr(fmt.Sprintf("%s Season %s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])),
					lo.ToPtr(fmt.Sprintf("%s S%s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])),
					lo.ToPtr(fmt.Sprintf("%s %s Season", torrentParsedData.Title, util.IntegerToOrdinal(util.StringToIntMust(torrentParsedData.SeasonNumber[0])))),
				}
			}
		}

		// If the parsed comparison title doesn't match, compare the torrent title with media titles
		mediaTitles := listEntry.GetMedia().GetAllTitles()
		var compRes *comparison.SorensenDiceResult
		for _, title := range torrentTitleVariations {
			res, found := comparison.FindBestMatchWithSorensenDice(title, mediaTitles)
			if found {
				if compRes == nil || res.Rating > compRes.Rating {
					compRes = res
				}
			}
		}

		// If the best match is not found
		// /!\ This shouldn't happen since the media titles are always present
		if compRes == nil {
			// Compare using rule comparison title
			sd := metrics.NewSorensenDice()
			sd.CaseSensitive = false
			res := sd.Compare(torrentTitle, comparisonTitle)

			if res > ComparisonThreshold {
				return true
			}
			return false
		}

		// If the best match is found
		if compRes.Rating > ComparisonThreshold {
			return true
		}

		return false
	}
	return false
}

func (ad *AutoDownloader) isSeasonAndEpisodeMatch(
	parsedData *habari.Metadata,
	rule *anime.AutoDownloaderRule,
	listEntry *anilist.AnimeListEntry,
	localEntry *anime.LocalFileWrapperEntry,
	items []*models.AutoDownloaderItem,
) (a int, b bool) {
	defer util.HandlePanicInModuleThen("autodownloader/isSeasonAndEpisodeMatch", func() {
		b = false
	})

	if listEntry == nil {
		return -1, false
	}

	episodes := parsedData.EpisodeNumber

	// Skip if we parsed more than one episode number (e.g. "01-02")
	// We can't handle this case since it might be a batch release
	if len(episodes) > 1 {
		return -1, false
	}

	var ok bool
	episode := 1
	if len(episodes) == 1 {
		_episode, _ok := util.StringToInt(episodes[0])
		if _ok {
			episode = _episode
			ok = true
		}
	}

	// +---------------------+
	// |  No episode number  |
	// +---------------------+

	// We can't parse the episode number
	if !ok {
		// Return true if the media (has only one episode or is a movie) AND (is not in the library)
		if listEntry.GetMedia().GetCurrentEpisodeCount() == 1 || *listEntry.GetMedia().GetFormat() == anilist.MediaFormatMovie {
			// Make sure it wasn't already added
			for _, item := range items {
				if item.Episode == 1 {
					return -1, false // Skip, file already queued or downloaded
				}
			}
			// Make sure it doesn't exist in the library
			if localEntry != nil {
				if _, found := localEntry.FindLocalFileWithEpisodeNumber(1); found {
					return -1, false // Skip, file already exists
				}
			}
			return 1, true // Good to go
		}
		return -1, false
	}

	// +---------------------+
	// |   Episode number    |
	// +---------------------+

	hasAbsoluteEpisode := false

	// Handle ABSOLUTE episode numbers
	if listEntry.GetMedia().GetCurrentEpisodeCount() != -1 && episode > listEntry.GetMedia().GetCurrentEpisodeCount() {
		// Fetch the Animap media in order to normalize the episode number
		ad.mu.Lock()
		animeMetadata, err := ad.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, listEntry.GetMedia().GetID())
		// If the media is found and the offset is greater than 0
		if err == nil && animeMetadata.GetOffset() > 0 {
			hasAbsoluteEpisode = true
			episode = episode - animeMetadata.GetOffset()
		}
		ad.mu.Unlock()
	}

	// Return false if the episode is already downloaded
	for _, item := range items {
		if item.Episode == episode {
			return -1, false // Skip, file already queued or downloaded
		}
	}

	// Return false if the episode is already in the library
	if localEntry != nil {
		if _, found := localEntry.FindLocalFileWithEpisodeNumber(episode); found {
			return -1, false
		}
	}

	// If there's no absolute episode number, check that the episode number is not greater than the current episode count
	if !hasAbsoluteEpisode && episode > listEntry.GetMedia().GetCurrentEpisodeCount() {
		return -1, false
	}

	// As a last check, make sure the seasons match ONLY if the episode number is not absolute
	// We do this check only for "likely" title comparison type since the season numbers are not compared
	if ad.settings.EnableSeasonCheck {
		if !hasAbsoluteEpisode {
			switch rule.TitleComparisonType {
			case anime.AutoDownloaderRuleTitleComparisonLikely:
				// If the title comparison type is "Likely", we will compare the season numbers
				if len(parsedData.SeasonNumber) > 0 {
					season, ok := util.StringToInt(parsedData.SeasonNumber[0])
					if ok && season > 1 {
						parsedComparisonTitle := habari.Parse(rule.ComparisonTitle)
						if len(parsedComparisonTitle.SeasonNumber) == 0 {
							return -1, false
						}
						if season != util.StringToIntMust(parsedComparisonTitle.SeasonNumber[0]) {
							return -1, false
						}
					}
				}
			}
		}
	}

	switch rule.EpisodeType {
	case anime.AutoDownloaderRuleEpisodeRecent:
		// +---------------------+
		// |  Episode "Recent"   |
		// +---------------------+
		// Return false if the user has already watched the episode
		if listEntry.Progress != nil && *listEntry.GetProgress() > episode {
			return -1, false
		}
		return episode, true // Good to go
	case anime.AutoDownloaderRuleEpisodeSelected:
		// +---------------------+
		// | Episode "Selected"  |
		// +---------------------+
		// Return true if the episode is in the list of selected episodes
		for _, ep := range rule.EpisodeNumbers {
			if ep == episode {
				return episode, true // Good to go
			}
		}
		return -1, false
	}
	return -1, false
}

func (ad *AutoDownloader) getRuleListEntry(rule *anime.AutoDownloaderRule) (*anilist.AnimeListEntry, bool) {
	if rule == nil || rule.MediaId == 0 || ad.animeCollection.IsAbsent() {
		return nil, false
	}

	listEntry, found := ad.animeCollection.MustGet().GetListEntryFromAnimeId(rule.MediaId)
	if !found {
		return nil, false
	}

	return listEntry, true
}
