using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Docnet.Core;
using Docnet.Core.Converters;
using Docnet.Core.Models;
using Docnet.Core.Readers;
using ExCSS;
using HtmlAgilityPack;
using Kavita.API.Database;
using Kavita.API.Services;
using Kavita.Common;
using Kavita.Common.Extensions;
using Kavita.Common.Helpers;
using Kavita.Models.DTOs.Reader;
using Kavita.Models.Entities;
using Kavita.Models.Entities.Enums;
using Kavita.Models.Entities.Enums.User;
using Kavita.Models.Extensions;
using Kavita.Models.Metadata;
using Kavita.Models.Parser;
using Kavita.Services.Extensions;
using Kavita.Services.Helpers;
using Kavita.Services.Metadata;
using Kavita.Services.Scanner;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using Nager.ArticleNumber;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using VersOne.Epub;
using VersOne.Epub.Options;
using VersOne.Epub.Schema;

namespace Kavita.Services;

public partial class BookService(
    ILogger<BookService> logger,
    IDirectoryService directoryService,
    IImageService imageService,
    IMediaErrorService mediaErrorService,
    IUnitOfWork unitOfWork)
    : IBookService
{
    private readonly StylesheetParser _cssParser = new ();
    private static readonly RecyclableMemoryStreamManager StreamManager = new ();
    private const string CssScopeClass = ".book-content";
    private const string BookApiUrl = "book-resources?apiKey={0}&file=";
    public const string BookReaderBodyScope = "//BODY/APP-ROOT[1]/DIV[1]/DIV[1]/DIV[1]/APP-BOOK-READER[1]/DIV[1]/DIV[2]/DIV[1]/DIV[1]/DIV[1]";

    private readonly PdfComicInfoExtractor _pdfComicInfoExtractor = new(logger, mediaErrorService);

    /// <summary>
    /// Setup the most lenient book parsing options possible as people have some really bad epubs
    /// </summary>
    public static readonly EpubReaderOptions BookReaderOptions = new()
    {
        PackageReaderOptions = new PackageReaderOptions
        {
            IgnoreMissingToc = true,
            SkipInvalidManifestItems = true,
        },
        Epub2NcxReaderOptions = new Epub2NcxReaderOptions
        {
            IgnoreMissingContentForNavigationPoints = false
        },
        SpineReaderOptions = new SpineReaderOptions
        {
            IgnoreMissingManifestItems = false
        },
        BookCoverReaderOptions =  new BookCoverReaderOptions
        {
            Epub2MetadataIgnoreMissingManifestItem = false
        }
    };

    public static readonly EpubReaderOptions LenientBookReaderOptions = new()
    {
        PackageReaderOptions = new PackageReaderOptions
        {
            IgnoreMissingToc = true,
            SkipInvalidManifestItems = true,
        },
        Epub2NcxReaderOptions = new Epub2NcxReaderOptions
        {
            IgnoreMissingContentForNavigationPoints = false
        },
        SpineReaderOptions = new SpineReaderOptions
        {
            IgnoreMissingManifestItems = false
        },
        BookCoverReaderOptions =  new BookCoverReaderOptions
        {
            Epub2MetadataIgnoreMissingManifestItem = true
        }
    };

    private static bool HasClickableHrefPart(HtmlNode anchor)
    {
        return anchor.GetAttributeValue("href", string.Empty).Contains('#')
               || anchor.GetAttributeValue("href", string.Empty).Contains(".xhtml")
               || anchor.GetAttributeValue("href", string.Empty).Contains(".html")
               && anchor.GetAttributeValue("tabindex", string.Empty) != "-1"
               && anchor.GetAttributeValue("role", string.Empty) != "presentation";
    }

    public static string GetContentType(EpubContentType type)
    {
        var contentType = type switch
        {
            EpubContentType.IMAGE_GIF => "image/gif",
            EpubContentType.IMAGE_PNG => "image/png",
            EpubContentType.IMAGE_JPEG => "image/jpeg",
            EpubContentType.FONT_OPENTYPE => "font/otf",
            EpubContentType.FONT_TRUETYPE => "font/ttf",
            EpubContentType.IMAGE_SVG => "image/svg+xml",
            _ => "application/octet-stream"
        };

        return contentType;
    }

    private static void UpdateLinks(HtmlNode anchor, Dictionary<string, int> mappings, int currentPage)
    {
        if (anchor.Name != "a") return;
        var hrefParts = CleanContentKeys(anchor.GetAttributeValue("href", string.Empty))
            .Split("#");
        // Some keys get uri encoded when parsed, so replace any of those characters with original
        var mappingKey = Uri.UnescapeDataString(hrefParts[0]);

        if (!mappings.TryGetValue(mappingKey, out var mappedPage))
        {
            if (HasClickableHrefPart(anchor))
            {
                var part = hrefParts.Length > 1
                    ? hrefParts[1]
                    : anchor.GetAttributeValue("href", string.Empty);

                // hrefParts[0] might not have path from mappings
                var pageKey = mappings.Keys.FirstOrDefault(mKey => mKey.EndsWith(hrefParts[0]));
                if (!string.IsNullOrEmpty(pageKey))
                {
                    mappings.TryGetValue(pageKey, out currentPage);
                }

                anchor.Attributes.Add("kavita-page", $"{currentPage}");
                anchor.Attributes.Add("kavita-part", part);
                anchor.Attributes.Remove("href");
                anchor.Attributes.Add("href", "javascript:void(0)");
            }
            else
            {
                anchor.Attributes.Add("target", "_blank");
                anchor.Attributes.Add("rel", "noreferrer noopener");
            }

            return;
        }

        anchor.Attributes.Add("kavita-page", $"{mappedPage}");
        if (hrefParts.Length > 1)
        {
            anchor.Attributes.Add("kavita-part",
                hrefParts[1]);
        }

        anchor.Attributes.Remove("href");
        anchor.Attributes.Add("href", "javascript:void(0)");
    }

    /// <summary>
    /// Scopes styles to .reading-section and replaces img src to the passed apiBase
    /// </summary>
    /// <param name="stylesheetHtml"></param>
    /// <param name="apiBase"></param>
    /// <param name="filename">If the stylesheetHtml contains Import statements, when scoping the filename, scope needs to be wrt filepath.</param>
    /// <param name="book">Book Reference, needed for if you expect Import statements</param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async Task<string> ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book,
        CancellationToken ct = default)
    {
        // @Import statements will be handled by browser, so we must inline the css into the original file that request it, so they can be Scoped
        var prepend = filename.Length > 0 ? filename.Replace(Path.GetFileName(filename), string.Empty) : string.Empty;
        var importBuilder = new StringBuilder();

        foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
        {
            if (!match.Success) continue;

            var importFile = match.Groups["Filename"].Value;
            var key = CleanContentKeys(importFile); // Validate if CoalesceKey works well here
            if (!key.Contains(prepend))
            {
                key = prepend + key;
            }
            if (!book.Content.AllFiles.TryGetLocalFileRefByKey(key, out var bookFile) || bookFile == null) continue;

            var content = await bookFile.ReadContentAsBytesAsync();
            importBuilder.Append(Encoding.UTF8.GetString(content));
        }

        stylesheetHtml = stylesheetHtml.Insert(0, importBuilder.ToString());

        EscapeCssImportReferences(ref stylesheetHtml, apiBase, prepend);

        EscapeFontFamilyReferences(ref stylesheetHtml, apiBase, prepend);


        // Check if there are any background images and rewrite those urls
        EscapeCssImageReferences(ref stylesheetHtml, apiBase, book);

        var styleContent = RemoveWhiteSpaceFromStylesheets(stylesheetHtml);

        styleContent = styleContent.Replace("body", CssScopeClass);

        if (string.IsNullOrEmpty(styleContent)) return string.Empty;

        var stylesheet = await _cssParser.ParseAsync(styleContent, ct);
        foreach (var styleRule in stylesheet.StyleRules)
        {
            if (styleRule.Selector.Text == CssScopeClass) continue;
            if (styleRule.Selector.Text.Contains(','))
            {
                styleRule.Text = styleRule.Text.Replace(styleRule.SelectorText,
                    string.Join(", ",
                        styleRule.Selector.Text.Split(",").Select(s => $"{CssScopeClass} " + s)));
                continue;
            }
            styleRule.Text = $"{CssScopeClass} " + styleRule.Text;
        }

        try
        {
            return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss());
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "There was an issue escaping css, likely due to an unsupported css rule");
        }

        return RemoveWhiteSpaceFromStylesheets($"{CssScopeClass} {styleContent}");
    }

    private static void EscapeCssImportReferences(ref string stylesheetHtml, string apiBase, string prepend)
    {
        foreach (Match match in Parser.CssImportUrlRegex.Matches(stylesheetHtml))
        {
            if (!match.Success) continue;
            var importFile = match.Groups["Filename"].Value;
            stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile);
        }
    }

    private static void EscapeFontFamilyReferences(ref string stylesheetHtml, string apiBase, string prepend)
    {
        foreach (Match match in Parser.FontSrcUrlRegex.Matches(stylesheetHtml))
        {
            if (!match.Success) continue;
            var importFile = match.Groups["Filename"].Value;
            stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + prepend + importFile);
        }
    }

    private static void EscapeCssImageReferences(ref string stylesheetHtml, string apiBase, EpubBookRef book)
    {
        var matches = Parser.CssImageUrlRegex.Matches(stylesheetHtml);
        foreach (Match match in matches)
        {
            if (!match.Success) continue;

            var importFile = match.Groups["Filename"].Value;
            var key = CleanContentKeys(importFile);
            if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) continue;

            stylesheetHtml = stylesheetHtml.Replace(importFile, apiBase + key);
        }
    }

    /// <summary>
    /// For each ptoc (text) bookmark on this page, inject a specialized icon
    /// </summary>
    /// <param name="doc"></param>
    /// <param name="ptocBookmarks"></param>
    private void InjectTextBookmarks(HtmlDocument doc, List<PersonalToCDto> ptocBookmarks)
    {
        if (ptocBookmarks.Count == 0) return;

        foreach (var bookmark in ptocBookmarks.Where(b => !string.IsNullOrEmpty(b.BookScrollId)))
        {
            try
            {
                var unscopedSelector = bookmark.BookScrollId!
                    .Replace(
                        BookReaderBodyScope,
                        "//BODY").ToLowerInvariant();
                var elem = doc.DocumentNode.SelectSingleNode(unscopedSelector);
                // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
                if (elem == null) continue;

                elem.PrependChild(HtmlNode.CreateNode(
                    $"<i class='fa-solid fa-bookmark ps-1 pe-1' role='button' id='ptoc-{bookmark.Id}' title='{bookmark.Title}'></i>"));
            }
            catch (Exception ex)
            {
                logger.LogWarning(ex, "Failed to inject a text (ptoc) bookmark into file");
               // Swallow
            }
        }
    }


    private static void InjectAnnotations(HtmlDocument doc, List<AnnotationDto> annotations)
    {
        if (annotations.Count == 0) return;

        var singleElementAnnotations = annotations
            .Where(a => !string.IsNullOrEmpty(a.XPath) && a.XPath == a.EndingXPath)
            .ToList();

        var multiElementAnnotations = annotations
            .Where(a => !string.IsNullOrEmpty(a.XPath) && !string.IsNullOrEmpty(a.EndingXPath) && a.XPath != a.EndingXPath)
            .ToList();

        AnnotationHelper.InjectSingleElementAnnotations(doc, singleElementAnnotations);
        AnnotationHelper.InjectMultiElementAnnotations(doc, multiElementAnnotations);
    }

    private static void ScopeImages(HtmlDocument doc, EpubBookRef book, string apiBase)
    {
        ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//img"));
        ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//image"));
        ScopeHtmlImageCollection(book, apiBase, doc.DocumentNode.SelectNodes("//svg"));
    }

    private static void ScopeHtmlImageCollection(EpubBookRef book, string apiBase, HtmlNodeCollection? images)
    {
        if (images == null) return;

        var parent = images[0].ParentNode;

        foreach (var image in images)
        {
            var key = GetImageSrcAttributeName(image);

            if (string.IsNullOrEmpty(key)) continue;

            var imageFile = GetKeyForImage(book, image.Attributes[key].Value);
            image.Attributes.Remove(key);

            if (!imageFile.StartsWith("http"))
            {
                // UrlEncode here to transform ../ into an escaped version, which avoids blocking on nginx
                image.Attributes.Add(key, $"{apiBase}" + Uri.EscapeDataString(imageFile));
            }
            else
            {
                image.Attributes.Add(key, imageFile);
            }

            // Add a custom class that the reader uses to ensure images stay within reader
            parent.AddClass("kavita-scale-width-container");
            image.AddClass("kavita-scale-width");
        }
    }


    /// <summary>
    /// Returns the image key associated with the file. Contains some basic fallback logic.
    /// </summary>
    /// <param name="book"></param>
    /// <param name="imageFile"></param>
    /// <returns></returns>
    private static string GetKeyForImage(EpubBookRef book, string imageFile)
    {
        if (book.Content.Images.ContainsLocalFileRefWithKey(imageFile)) return imageFile;

        var correctedKey = book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile));
        if (correctedKey != null)
        {
            imageFile = correctedKey;
        }
        else if (imageFile.StartsWith(".."))
        {
            // There are cases where the key is defined static like OEBPS/Images/1-4.jpg but reference is ../Images/1-4.jpg
            correctedKey =
                book.Content.Images.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(imageFile.Replace("..", string.Empty)));
            if (correctedKey != null)
            {
                imageFile = correctedKey;
            }
        }


        return imageFile;
    }

    private static string PrepareFinalHtml(HtmlDocument doc, HtmlNode body)
    {
        // Check if any classes on the html node (some r2l books do this) and move them to body tag for scoping
        var htmlNode = doc.DocumentNode.SelectSingleNode("//html");
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (htmlNode == null) return body.InnerHtml;

        var bodyClasses = body.Attributes.Contains("class") ? body.Attributes["class"].Value : string.Empty;
        var htmlClasses = htmlNode.Attributes.Contains("class") ? htmlNode.Attributes["class"].Value : string.Empty;

        body.Attributes.Add("class", $"{htmlClasses} {bodyClasses}");


        // I actually need the body tag itself for the classes, so i will create a div and put the body stuff there.
        return $"<div class=\"{body.Attributes["class"].Value}\">{body.InnerHtml}</div>";
    }

    private static void RewriteAnchors(int page, HtmlDocument doc, Dictionary<string, int> mappings)

    {
        var anchors = doc.DocumentNode.SelectNodes("//a");
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (anchors == null) return;

        foreach (var anchor in anchors)
        {
            UpdateLinks(anchor, mappings, page);
        }
    }

    private async Task InlineStyles(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, CancellationToken ct = default)
    {
        var inlineStyles = doc.DocumentNode.SelectNodes("//style");
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (inlineStyles != null)
        {
            foreach (var inlineStyle in inlineStyles)
            {
                var styleContent = await ScopeStyles(inlineStyle.InnerHtml, apiBase, "", book, ct);
                body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>"));
            }
        }

        var styleNodes = doc.DocumentNode.SelectNodes("/html/head/link[@href]");
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (styleNodes != null)
        {
            foreach (var styleLinks in styleNodes)
            {
                var key = CleanContentKeys(styleLinks.Attributes["href"].Value);
                // Some epubs are malformed the key in content.opf might be: content/resources/filelist_0_0.xml but the actual html links to resources/filelist_0_0.xml
                // In this case, we will do a search for the key that ends with
                if (!book.Content.Css.ContainsLocalFileRefWithKey(key))
                {
                    var correctedKey = book.Content.Css.Local.Select(s => s.Key).SingleOrDefault(s => s.EndsWith(key));
                    if (correctedKey == null)
                    {
                        logger.LogError("Epub is Malformed, key: {Key} is not matching OPF file", key);
                        continue;
                    }

                    key = correctedKey;
                }

                try
                {
                    var cssFile = book.Content.Css.GetLocalFileRefByKey(key);

                    var stylesheetHtml = await cssFile.ReadContentAsync();
                    var styleContent = await ScopeStyles(stylesheetHtml, apiBase, cssFile.FilePath, book, ct);
                    // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
                    if (styleContent != null)
                    {
                        body.PrependChild(HtmlNode.CreateNode($"<style>{styleContent}</style>"));
                    }
                }
                catch (Exception ex)
                {
                    logger.LogError(ex, "There was an error reading css file for inlining likely due to a key mismatch in metadata");
                    await mediaErrorService.ReportMediaIssueAsync(book.FilePath ?? string.Empty, MediaErrorProducer.BookService,
                        "There was an error reading css file for inlining likely due to a key mismatch in metadata", ex, ct);
                }
            }
        }
    }

    private ComicInfo? GetEpubComicInfo(string filePath)
    {
        EpubBookRef? epubBook = null;

        try
        {
            epubBook = OpenEpubWithFallback(filePath, epubBook);
            if (epubBook == null) return null;

            var info = BuildBaseComicInfo(epubBook);
            info.CleanComicInfo();

            ApplyIdentifiers(epubBook, info, filePath);
            ApplyMetadataItems(epubBook, info, out var refinedCreatorIds);
            ApplyCreators(epubBook, info, refinedCreatorIds);
            ApplySeriesFallbacks(info, filePath);

            return info;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing metadata: {FilePath}", filePath);
            mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
                "There was an exception parsing metadata", ex);
        }
        finally
        {
            epubBook?.Dispose();
        }

        return null;
    }

    private void ApplyIdentifiers(EpubBookRef epubBook, ComicInfo info, string filePath)
    {
        var identifiers = epubBook.Schema.Package.Metadata.Identifiers;
        if (identifiers == null) return;

        var weblinks = new List<string>();
        foreach (var identifier in identifiers)
        {
            if (string.IsNullOrEmpty(identifier.Identifier)) continue;

            if (IsIsbnScheme(identifier))
            {
                TryApplyIsbn(identifier, info, filePath);
            }

            if (IsUrlScheme(identifier))
            {
                weblinks.Add(identifier.Identifier.Replace("url:", string.Empty).Trim());
            }
        }

        if (weblinks.Count > 0)
        {
            info.Web = string.Join(',', weblinks.Distinct());
        }
    }

    private static bool IsIsbnScheme(EpubMetadataIdentifier identifier) =>
        !string.IsNullOrEmpty(identifier.Scheme) &&
        identifier.Scheme.Equals("ISBN", StringComparison.InvariantCultureIgnoreCase);

    private static bool IsUrlScheme(EpubMetadataIdentifier identifier) =>
        (!string.IsNullOrEmpty(identifier.Scheme) &&
         identifier.Scheme.Equals("URL", StringComparison.InvariantCultureIgnoreCase)) ||
        identifier.Identifier.StartsWith("url:");

    private void TryApplyIsbn(EpubMetadataIdentifier identifier, ComicInfo info, string filePath)
    {
        var isbn = identifier.Identifier
            .Replace("urn:isbn:", string.Empty)
            .Replace("isbn:", string.Empty);

        if (!ArticleNumberHelper.IsValidIsbn10(isbn) && !ArticleNumberHelper.IsValidIsbn13(isbn))
        {
            logger.LogDebug("[BookService] {File} has invalid ISBN number", filePath);
            return;
        }

        info.Isbn = isbn;
    }

    private static ComicInfo BuildBaseComicInfo(EpubBookRef epubBook)
    {
        var publicationDate = epubBook?.Schema.Package.Metadata.Dates.Find(pDate => pDate.Event == "publication")?.Date;

        if (string.IsNullOrEmpty(publicationDate))
        {
            publicationDate = epubBook?.Schema.Package.Metadata.Dates.FirstOrDefault()?.Date;
        }

        var (year, month, day) = GetPublicationDate(publicationDate);

        var summary = epubBook?.Schema.Package.Metadata.Descriptions.FirstOrDefault();
        var info = new ComicInfo
        {
            Summary = string.IsNullOrEmpty(summary?.Description) ? string.Empty : summary.Description,
            Publisher = string.Join(",", epubBook?.Schema.Package.Metadata.Publishers.Select(p => p.Publisher) ?? []),
            Month = month,
            Day = day,
            Year = year,
            Title = epubBook?.Title ?? string.Empty,
            Genre = string.Join(",",
                epubBook?.Schema.Package.Metadata.Subjects.Select(s => s.Subject.ToLower().Trim()) ?? []),
            LanguageISO = ValidateLanguage(epubBook?.Schema.Package.Metadata.Languages
                .Select(l => l.Language)
                .FirstOrDefault())
        };
        return info;
    }

    private static void ApplyMetadataItems(EpubBookRef epubBook, ComicInfo info, out HashSet<string> refinedCreatorIds)
    {
        refinedCreatorIds = [];
        var metaItems = epubBook.Schema.Package.Metadata.MetaItems;
        if (metaItems == null) return;

        foreach (var item in metaItems)
        {
            ApplyEpub2Metadata(item, info);
            ApplyEpub3Metadata(item, info, epubBook, refinedCreatorIds);
        }
    }

    private static void ApplyEpub2Metadata(EpubMetadataMeta item, ComicInfo info)
    {
        switch (item.Name)
        {
            case "calibre:rating":
                info.UserRating = item.Content.AsFloat();
                break;
            case "calibre:title_sort":
                info.TitleSort = item.Content;
                break;
            case "calibre:series":
                info.Series = item.Content;
                if (string.IsNullOrEmpty(info.SeriesSort))
                {
                    info.SeriesSort = item.Content;
                }
                break;
            case "calibre:series_index":
                info.Volume = item.Content;
                break;
        }
    }

    private static void ApplyEpub3Metadata(EpubMetadataMeta item, ComicInfo info, EpubBookRef epubBook, HashSet<string> refinedCreatorIds)
    {
        switch (item.Property)
        {
            case "group-position":
                info.Volume = item.Content;
                break;
            case "belongs-to-collection":
                info.Series = item.Content;
                if (string.IsNullOrEmpty(info.SeriesSort)) info.SeriesSort = item.Content;
                break;
            case "role":
                ApplyRoleRefinement(item, info, epubBook, refinedCreatorIds);
                break;
            case "title-type":
                if (item.Content.Equals("collection")) ExtractCollectionOrReadingList(item, epubBook, info);
                if (item.Content.Equals("main")) ExtractSortTitle(item, epubBook, info);
                break;
        }
    }

    private static void ApplyRoleRefinement(EpubMetadataMeta item, ComicInfo info, EpubBookRef epubBook, HashSet<string> refinedCreatorIds)
    {
        if (item.Scheme != null && !item.Scheme.Equals("marc:relators")) return;

        var creatorId = item.Refines?.Replace("#", string.Empty);
        if (string.IsNullOrEmpty(creatorId)) return;

        var person = epubBook.Schema.Package.Metadata.Creators.SingleOrDefault(c => c.Id == creatorId);
        if (person == null) return;

        PopulatePerson(item, info, person);
        refinedCreatorIds.Add(creatorId);
    }

    private static void ApplyCreators(EpubBookRef epubBook, ComicInfo info, HashSet<string> refinedCreatorIds)
    {
        // Creators without a role refinement are assumed to be writers.
        // This handles both: EPUBs with no refinements at all, and EPUBs
        // where only some creators have refinements (mixed case).
        var unrefinedCreators = epubBook.Schema.Package.Metadata.Creators
            .Where(c => string.IsNullOrEmpty(c.Id) || !refinedCreatorIds.Contains(c.Id))
            .Select(c => Parser.CleanAuthor(c.Creator))
            .Where(name => !string.IsNullOrEmpty(name))
            .ToList();

        var trimmedExisting = info.Writer.TrimEnd(',');

        if (unrefinedCreators.Count == 0)
        {
            info.Writer = trimmedExisting;
            return;
        }

        var joined = string.Join(",", unrefinedCreators);
        info.Writer = string.IsNullOrEmpty(trimmedExisting) ? joined.TrimEnd(',') : $"{joined},{trimmedExisting}";
    }

    private static void ApplySeriesFallbacks(ComicInfo info, string filePath)
    {
        // If this is a single book and not a collection, set publication status to Completed
        if (string.IsNullOrEmpty(info.Volume) &&
            Parser.IsLooseLeafVolume(Parser.ParseVolume(filePath, LibraryType.Manga)))
        {
            info.Count = 1;
        }

        var hasVolumeInSeries = !Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Title, LibraryType.Manga));

        if (string.IsNullOrEmpty(info.Volume) && hasVolumeInSeries &&
            (!info.Series.Equals(info.Title) || string.IsNullOrEmpty(info.Series)))
        {
            // This is likely a light novel for which we can set series from parsed title
            info.Series = Parser.ParseSeries(info.Title, LibraryType.Manga);
            info.Volume = Parser.ParseVolume(info.Title, LibraryType.Manga);
        }
    }

    private EpubBookRef? OpenEpubWithFallback(string filePath, EpubBookRef? epubBook)
    {
        // default: Refactor this to use the Async version
        try
        {
            epubBook = EpubReader.OpenBook(filePath, BookReaderOptions);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex,
                "[GetComicInfo] There was an exception parsing metadata, falling back to a more lenient parsing method: {FilePath}",
                filePath);
            mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
                "There was an exception parsing metadata", ex);
        }
        finally
        {
            epubBook ??= EpubReader.OpenBook(filePath, LenientBookReaderOptions);
        }

        return epubBook;
    }

    public ComicInfo? GetComicInfo(string filePath)
    {
        if (!IsValidFile(filePath)) return null;

        if (Parser.IsPdf(filePath))
        {
            return _pdfComicInfoExtractor.GetComicInfo(filePath);
        }

        return GetEpubComicInfo(filePath);
    }

    private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
    {
        var titleId = metadataItem.Refines?.Replace("#", string.Empty);
        var titleElem = epubBook.Schema.Package.Metadata.Titles
            .Find(item => item.Id == titleId);
        if (titleElem == null) return;

        var sortTitleElem = epubBook.Schema.Package.Metadata.MetaItems
            .Find(item =>
                item.Property == "file-as" && item.Refines == metadataItem.Refines);
        if (sortTitleElem == null || string.IsNullOrWhiteSpace(sortTitleElem.Content)) return;
        info.SeriesSort = sortTitleElem.Content;
    }

    private static void ExtractCollectionOrReadingList(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info)
    {
        var titleId = metadataItem.Refines?.Replace("#", string.Empty);
        var readingListElem = epubBook.Schema.Package.Metadata.Titles
            .Find(item => item.Id == titleId);
        if (readingListElem == null) return;

        var count = epubBook.Schema.Package.Metadata.MetaItems
            .Find(item =>
                item.Property == "display-seq" && item.Refines == metadataItem.Refines);
        if (count == null || count.Content == "0")
        {
            // Treat this as a Collection
            info.SeriesGroup += (string.IsNullOrEmpty(info.StoryArc) ? string.Empty : ",") +
                                readingListElem.Title.Replace(',', '_');
        }
        else
        {
            // Treat as a reading list
            info.AlternateSeries += (string.IsNullOrEmpty(info.AlternateSeries) ? string.Empty : ",") +
                                    readingListElem.Title.Replace(',', '_');
            info.AlternateNumber += (string.IsNullOrEmpty(info.AlternateNumber) ? string.Empty : ",") + count.Content;
        }
    }

    private static void PopulatePerson(EpubMetadataMeta metadataItem, ComicInfo info, EpubMetadataCreator person)
    {
        switch (metadataItem.Content)
        {
            case "art":
            case "artist":
                info.CoverArtist += AppendAuthor(person);
                return;
            case "aut":
            case "author":
            case "creator":
            case "cre":
                info.Writer += AppendAuthor(person);
                return;
            case "pbl":
            case "publisher":
                info.Publisher += AppendAuthor(person);
                return;
            case "trl":
            case "translator":
                info.Translator += AppendAuthor(person);
                return;
            case "edt":
            case "editor":
                info.Editor += AppendAuthor(person);
                return;
            case "ill":
            case "illustrator":
                info.Inker += AppendAuthor(person);
                return;
            case "clr":
            case "colorist":
                info.Colorist += AppendAuthor(person);
                return;
        }
    }

    private static string AppendAuthor(EpubMetadataCreator person)
    {
        return Parser.CleanAuthor(person.Creator) + ",";
    }

    private static (int year, int month, int day) GetPublicationDate(string? publicationDate)
    {
        var year = 0;
        var month = 0;
        var day = 0;
        if (string.IsNullOrEmpty(publicationDate)) return (year, month, day);
        switch (DateTime.TryParse(publicationDate, CultureInfo.InvariantCulture, out var date))
        {
            case true:
                year = date.Year;
                month = date.Month;
                day = date.Day;
                break;
            case false when !string.IsNullOrEmpty(publicationDate) && publicationDate.Length == 4:
                int.TryParse(publicationDate, out year);
                break;
        }

        return (year, month, day);
    }

    public static string ValidateLanguage(string? language)
    {
        if (string.IsNullOrEmpty(language)) return string.Empty;

        try
        {
            return CultureInfo.GetCultureInfo(language).ToString();
        }
        catch (Exception)
        {
            return string.Empty;
        }
    }

    private bool IsValidFile(string filePath)
    {
        if (!File.Exists(filePath))
        {
            logger.LogWarning("[BookService] Book {EpubFile} could not be found", filePath);
            return false;
        }

        if (Parser.IsBook(filePath)) return true;

        logger.LogWarning("[BookService] Book {EpubFile} is not a valid EPUB/PDF", filePath);
        return false;
    }

    public int GetNumberOfPages(string filePath)
    {
        if (!IsValidFile(filePath)) return 0;

        try
        {
            if (Parser.IsPdf(filePath))
            {
                using var docReader = DocLib.Instance.GetDocReader(filePath, new PageDimensions(1080, 1920));
                return docReader.GetPageCount();
            }

            using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions);
            return epubBook.GetReadingOrder().Count;
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "[BookService] There was an exception getting number of pages, defaulting to 0");
            mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
                "There was an exception getting number of pages, defaulting to 0", ex);
        }

        return 0;
    }

    private static string EscapeTags(string content)
    {
        content = Regex.Replace(content, @"<script(.*)(/>)", "<script$1></script>", RegexOptions.None, Parser.RegexTimeout);
        content = Regex.Replace(content, @"<title(.*)(/>)", "<title$1></title>", RegexOptions.None, Parser.RegexTimeout);
        return content;
    }

    /// <summary>
    /// Removes all leading ../
    /// </summary>
    /// <param name="key"></param>
    /// <returns></returns>
    public static string CleanContentKeys(string key)
    {
        return key.Replace("../", string.Empty);
    }

    public async Task<Dictionary<string, int>> CreateKeyToPageMappingAsync(EpubBookRef book,
        CancellationToken ct = default)
    {
        var dict = new Dictionary<string, int>();
        var pageCount = 0;
        foreach (var contentFileRef in await book.GetReadingOrderAsync())
        {
            if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) continue;
            // Some keys are different than FilePath, so we add both to ease loookup
            dict.Add(contentFileRef.FilePath, pageCount); // FileName -> FilePath
            dict.TryAdd(contentFileRef.Key, pageCount); // FileName -> FilePath
            pageCount += 1;
        }

        return dict;
    }

    public async Task<IDictionary<int, int>?> GetWordCountsPerPage(string bookFilePath, CancellationToken ct = default)
    {
        var ret = new Dictionary<int, int>();
        try
        {
            using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
            if (book == null) return null;

            var mappings = await CreateKeyToPageMappingAsync(book, ct);

            var doc = new HtmlDocument {OptionFixNestedTags = true};


            var bookPages = await book.GetReadingOrderAsync();
            foreach (var contentFileRef in bookPages)
            {
                var page = mappings[contentFileRef.Key];
                var content = await contentFileRef.ReadContentAsync();
                doc.LoadHtml(content);

                var body = GetBodyOrCreate(doc, book);

                // Find all words in the html body
                ret.Add(page, CountLettersInBody(body));
            }

        }
        catch (Exception ex)
        {
            logger.LogError(ex, "There was an issue calculating word counts per page");
            return null;
        }

        return ret;
    }

    private HtmlNode GetBodyOrCreate(HtmlDocument doc, EpubBookRef book)
    {
        var body = doc.DocumentNode.SelectSingleNode("//body");

        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (body == null)
        {
            logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
            doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("<body></body>"));
            body = doc.DocumentNode.SelectSingleNode("//html/body");
        }

        return body;
    }

    private static int CountLettersUpToNode(HtmlNode root, HtmlNode targetNode)
    {
        var letterCount = 0;
        var foundTarget = false;

        TraverseNodes(root);
        return letterCount;

        void TraverseNodes(HtmlNode node)
        {
            if (foundTarget) return;

            if (node == targetNode)
            {
                foundTarget = true;
                return;
            }

            // If it's a text node and not inside a script tag
            if (node.NodeType == HtmlNodeType.Text && node.ParentNode?.Name != "script")
            {
                letterCount += node.InnerText.Count(char.IsLetter);
            }

            // Traverse children
            foreach (var child in node.ChildNodes)
            {
                TraverseNodes(child);
                if (foundTarget) return;
            }
        }
    }


    #region Count Letters Between XPaths

    /// <summary>
    /// Counts the (estimated) words for a given book from a starting xpath (or beginning if null) to and ending xpath.
    /// May cross page boundaries
    /// </summary>
    /// <param name="bookFilePath"></param>
    /// <param name="startXpath"></param>
    /// <param name="startPage">Page number of starting xpath</param>
    /// <param name="endXpath"></param>
    /// <param name="endPage">Page number of ending xpath</param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async Task<int> GetWordCountBetweenXPaths(string bookFilePath, string startXpath, int startPage,
        string endXpath, int endPage, CancellationToken ct = default)
    {
        if (string.IsNullOrEmpty(endXpath)) return 0;
        if (endPage < startPage) return 0;

        var totalCharacters = 0;

        try
        {
            using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
            if (book == null) return 0;

            var doc = new HtmlDocument { OptionFixNestedTags = true };
            var bookPages = await book.GetReadingOrderAsync();
            var pageList = bookPages.ToList();

            // Completed books have endPage equal to pageList.Count (For the UI); We need to adjust this shift
            // To calculate the read words correctly
            if (endPage == pageList.Count)
            {
                endPage--;
            }

            // Validate page bounds
            if (startPage < 0 || endPage >= pageList.Count) return 0;

            for (var pageIndex = startPage; pageIndex <= endPage; pageIndex++)
            {
                var contentFileRef = pageList[pageIndex];
                var content = await contentFileRef.ReadContentAsync();
                doc.LoadHtml(content);

                var body = GetBodyOrCreate(doc, book);

                var isStartPage = pageIndex == startPage;
                var isEndPage = pageIndex == endPage;

                // Case 1: Start and end on the same page
                if (isStartPage && isEndPage)
                {
                    var startNode = string.IsNullOrEmpty(startXpath) ? null : body.SelectSingleNode(startXpath);
                    var endNode = body.SelectSingleNode(endXpath);

                    if (startNode != null && endNode != null)
                    {
                        totalCharacters += CountLettersBetweenNodes(body, startNode, endNode);
                    }
                    else if (endNode != null)
                    {
                        // No start xpath, count from beginning to end node
                        totalCharacters += CountLettersUpToNode(body, endNode);
                    }
                    break;
                }

                // Case 2: Start page - count from start node to end of page
                if (isStartPage)
                {
                    if (string.IsNullOrEmpty(startXpath))
                    {
                        totalCharacters += CountLettersInBody(body);
                    }
                    else
                    {
                        var startNode = body.SelectSingleNode(startXpath);
                        totalCharacters += startNode != null
                            ? CountLettersFromNode(body, startNode)
                            : CountLettersInBody(body);
                    }
                    continue;
                }

                // Case 3: End page - count from beginning to end node
                if (isEndPage)
                {
                    var endNode = body.SelectSingleNode(endXpath);
                    totalCharacters += endNode != null
                        ? CountLettersUpToNode(body, endNode)
                        : CountLettersInBody(body);
                    break;
                }

                // Case 4: Middle page - count entire page
                totalCharacters += CountLettersInBody(body);
            }
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "There was an issue calculating word counts between XPaths");
            return 0;
        }

        return WordCountAnalyzerService.GetWordCount(totalCharacters);
    }

    /// <summary>
    /// Counts letters from a starting node to the end of the container
    /// </summary>
    private static int CountLettersFromNode(HtmlNode container, HtmlNode startNode)
    {
        var letterCount = 0;
        var countingStarted = false;

        TraverseNodes(container);
        return letterCount;

        void TraverseNodes(HtmlNode node)
        {
            if (node == startNode)
            {
                countingStarted = true;
                // Don't return here - we want to start counting from this node onwards
            }

            if (countingStarted && node.NodeType == HtmlNodeType.Text && node.ParentNode?.Name != "script")
            {
                letterCount += node.InnerText.Count(char.IsLetter);
            }

            foreach (var child in node.ChildNodes)
            {
                TraverseNodes(child);
            }
        }
    }

    /// <summary>
    /// Counts letters between two nodes in the same container
    /// </summary>
    private static int CountLettersBetweenNodes(HtmlNode container, HtmlNode startNode, HtmlNode endNode)
    {
        var letterCount = 0;
        var countingStarted = false;
        var foundEnd = false;

        TraverseNodes(container);
        return letterCount;

        void TraverseNodes(HtmlNode node)
        {
            if (foundEnd) return;

            if (node == startNode)
            {
                countingStarted = true;
                return; // Start counting after this node
            }

            if (node == endNode)
            {
                foundEnd = true;
                return;
            }

            if (countingStarted && node.NodeType == HtmlNodeType.Text && node.ParentNode?.Name != "script")
            {
                letterCount += node.InnerText.Count(char.IsLetter);
            }

            foreach (var child in node.ChildNodes)
            {
                TraverseNodes(child);
            }
        }
    }
    #endregion


    private static int CountLettersInBody(HtmlNode body)
    {
        var textNodes = body.SelectNodes("//text()[not(parent::script)]");
        return textNodes?.Sum(node => node.InnerText.Count(char.IsLetter)) ?? 0;
    }

    public async Task<string> CopyImageToTempFromBook(int chapterId, BookmarkDto bookmarkDto, string cachedBookPath,
        CancellationToken ct = default)
    {
        using var book = await EpubReader.OpenBookAsync(cachedBookPath, LenientBookReaderOptions);

        var counter = 0;
        var doc = new HtmlDocument { OptionFixNestedTags = true };

        var bookPages = await book.GetReadingOrderAsync();
        foreach (var contentFileRef in bookPages)
        {
            if (bookmarkDto.Page != counter || contentFileRef.ContentType != EpubContentType.XHTML_1_1)
            {
                counter++;
                continue;
            }

            var content = await contentFileRef.ReadContentAsync();
            doc.LoadHtml(content);

            var images = doc.DocumentNode.SelectNodes("//img")
                         ?? doc.DocumentNode.SelectNodes("//image");

            if (images == null || images.Count == 0)
            {
                throw new KavitaException("No images found on the specified page");
            }

            if (bookmarkDto.ImageOffset >= images.Count)
            {
                throw new KavitaException($"Image index {bookmarkDto.ImageOffset} is out of range. Page has {images.Count} images");
            }

            var targetImage = images[bookmarkDto.ImageOffset];

            // Get the image source attribute
            var srcAttributeName = GetImageSrcAttributeName(targetImage);

            if (string.IsNullOrEmpty(srcAttributeName))
            {
                throw new KavitaException("Image element does not have a valid source attribute");
            }

            var imageSource = targetImage.Attributes[srcAttributeName].Value;

            // Clean and get the correct key for the image
            var imageKey = CleanContentKeys(GetKeyForImage(book, imageSource));

            // Check if it's an external URL
            if (imageKey.StartsWith("http"))
            {
                throw new KavitaException("Cannot copy external images");
            }

            // Get the image file from the epub

            if (!book.Content.Images.TryGetLocalFileRefByKey(imageKey, out var imageFile))
            {
                throw new KavitaException($"Image file not found in epub: {imageKey}");
            }

            // Read the image content
            var imageContent = await imageFile.ReadContentAsBytesAsync();

            // Determine file extension from the image key or content type
            var extension = Path.GetExtension(imageKey);
            if (string.IsNullOrEmpty(extension))
            {
                // Fallback to determining extension from content type
                extension = imageFile.ContentType switch
                {
                    EpubContentType.IMAGE_JPEG => ".jpg",
                    EpubContentType.IMAGE_PNG => ".png",
                    EpubContentType.IMAGE_GIF => ".gif",
                    EpubContentType.IMAGE_SVG => ".svg",
                    _ => ".png"
                };
            }

            // Create temp directory for this chapter if it doesn't exist
            var tempChapterDir = Path.Combine(directoryService.TempDirectory, chapterId.ToString());
            directoryService.ExistOrCreate(tempChapterDir);

            // Generate unique filename
            var uniqueFilename = $"{Guid.NewGuid()}{extension}";
            var tempFilePath = Path.Combine(tempChapterDir, uniqueFilename);

            // Write the image to the temp file
            await File.WriteAllBytesAsync(tempFilePath, imageContent, ct);

            return tempFilePath;
        }

        throw new KavitaException($"Page {bookmarkDto.Page} not found in epub");
    }

    private static string? GetImageSrcAttributeName(HtmlNode targetImage)
    {
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (targetImage.Attributes["src"] != null)
        {
            return "src";
        }
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (targetImage.Attributes["xlink:href"] != null)
        {
            return "xlink:href";
        }

        return null;
    }

    /// <summary>
    /// Attempts to resolve a requested key path with some hacks to attempt to handle incorrect metadata
    /// </summary>
    /// <param name="bookFilePath"></param>
    /// <param name="requestedKey"></param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async Task<BookResourceResultDto> GetResourceAsync(string bookFilePath, string requestedKey,
        CancellationToken ct = default)
    {
        using var book = await EpubReader.OpenBookAsync(bookFilePath, LenientBookReaderOptions);
        var key = CoalesceKeyForAnyFile(book, requestedKey);

        if (!book.Content.AllFiles.ContainsLocalFileRefWithKey(key))
            return BookResourceResultDto.Error("file-missing");

        var bookFile = book.Content.AllFiles.GetLocalFileRefByKey(key);
        var content = await bookFile.ReadContentAsBytesAsync();
        var contentType = GetContentType(bookFile.ContentType);

        return BookResourceResultDto.Success(content, contentType, requestedKey);
    }


    /// <summary>
    /// Parses out Title from book. Chapters and Volumes will always be "0". If there is any exception reading book (malformed books)
    /// then null is returned. This expects only an epub file
    /// </summary>
    /// <param name="filePath"></param>
    /// <returns></returns>
    public ParserInfo? ParseInfo(string filePath)
    {
        if (!Parser.IsEpub(filePath) || !directoryService.FileSystem.File.Exists(filePath)) return null;

        try
        {
            using var epubBook = EpubReader.OpenBook(filePath, LenientBookReaderOptions);

            // <meta content="The Dark Tower" name="calibre:series"/>
            // <meta content="Wolves of the Calla" name="calibre:title_sort"/>
            // If all three are present, we can take that over dc:title and format as:
            // Series = The Dark Tower, Volume = 5, Filename as "Wolves of the Calla"
            // In addition, the following can exist and should parse as a series (EPUB 3.2 spec)
            // <meta property="belongs-to-collection" id="c01">
            //   The Lord of the Rings
            // </meta>
            // <meta refines="#c01" property="collection-type">set</meta>
            // <meta refines="#c01" property="group-position">2</meta>
            try
            {
                var seriesIndex = string.Empty;
                var series = string.Empty;
                var specialName = string.Empty;


                foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems)
                {
                    // EPUB 2 and 3
                    switch (metadataItem.Name)
                    {
                        case "calibre:series_index":
                            seriesIndex = metadataItem.Content;
                            break;
                        case "calibre:series":
                            series = metadataItem.Content;
                            break;
                        case "calibre:title_sort":
                            specialName = metadataItem.Content;
                            break;
                    }

                    // EPUB 3.2+ only
                    switch (metadataItem.Property)
                    {
                        case "group-position":
                            seriesIndex = metadataItem.Content;
                            break;
                        case "belongs-to-collection":
                            series = metadataItem.Content;
                            break;
                        case "collection-type":
                            // These look to be genres from https://manual.calibre-ebook.com/sub_groups.html or can be "series"
                            break;
                    }
                }

                if (!string.IsNullOrEmpty(series) && !string.IsNullOrEmpty(seriesIndex))
                {
                    if (string.IsNullOrEmpty(specialName))
                    {
                        specialName = epubBook.Title;
                    }
                    var info = new ParserInfo
                    {
                        Chapters = Parser.DefaultChapter,
                        Edition = string.Empty,
                        Format = MangaFormat.Epub,
                        Filename = Path.GetFileName(filePath),
                        Title = specialName?.Trim() ?? string.Empty,
                        FullFilePath = Parser.NormalizePath(filePath),
                        IsSpecial = Parser.HasSpecialMarker(filePath),
                        Series = series.Trim(),
                        SeriesSort = series.Trim(),
                        Volumes = seriesIndex
                    };

                    return info;
                }
            }
            catch (Exception)
            {
                // Swallow exception
            }

            return new ParserInfo
            {
                Chapters = Parser.DefaultChapter,
                Edition = string.Empty,
                Format = MangaFormat.Epub,
                Filename = Path.GetFileName(filePath),
                Title = epubBook.Title.Trim(),
                FullFilePath = Parser.NormalizePath(filePath),
                IsSpecial = Parser.HasSpecialMarker(filePath),
                Series = epubBook.Title.Trim(),
                Volumes = Parser.LooseLeafVolume,
            };
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "[BookService] There was an exception when opening epub book: {FileName}", filePath);
            mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService,
                "There was an exception when opening epub book", ex);
        }

        return null;
    }

    /// <summary>
    /// Extracts a pdf into images to a target directory. Uses multithreaded implementation since docnet is slow normally.
    /// </summary>
    /// <param name="fileFilePath"></param>
    /// <param name="targetDirectory"></param>
    public void ExtractPdfImages(string fileFilePath, string targetDirectory)
    {
        directoryService.ExistOrCreate(targetDirectory);

        var settings = unitOfWork.SettingsRepository.GetSettingsDtoAsync().GetAwaiter().GetResult();
        var dims = settings.PdfRenderResolution.GetDimensions();
        var pageDimensions = new PageDimensions(dims.dim1, dims.dim2);

        int pages;
        using (var countReader = DocLib.Instance.GetDocReader(fileFilePath, pageDimensions))
        {
            pages = countReader.GetPageCount();
        }

        var parallelOptions = new ParallelOptions
        {
            MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 4)
        };

        Parallel.For(0, pages, parallelOptions, pageNumber =>
        {
            using var docReader = DocLib.Instance.GetDocReader(fileFilePath, pageDimensions);
            using var stream = StreamManager.GetStream("BookService.GetPdfPage");

            GetPdfPage(docReader, pageNumber, stream);

            var outputPath = Path.Combine(targetDirectory, $"Page-{pageNumber}.png");
            using var fileStream = new FileStream(
                outputPath,
                FileMode.Create,
                FileAccess.Write,
                FileShare.None,
                bufferSize: 81920);

            stream.Seek(0, SeekOrigin.Begin);
            stream.CopyTo(fileStream);
        });
    }

    /// <summary>
    /// Responsible to scope all the css, links, tags, etc to prepare a self contained html file for the page
    /// </summary>
    /// <param name="doc">Html Doc that will be appended to</param>
    /// <param name="book">Underlying epub</param>
    /// <param name="apiBase">API Url for file loading to pass through</param>
    /// <param name="body">Body element from the epub</param>
    /// <param name="mappings">Epub mappings</param>
    /// <param name="page">Page number we are loading</param>
    /// <param name="ptocBookmarks">Ptoc (Text) Bookmarks to tie against</param>
    /// <param name="annotations"></param>
    /// <param name="ct"></param>
    /// <returns></returns>
    private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body,
        Dictionary<string, int> mappings, int page, List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations,
        CancellationToken ct = default)
    {
        await InlineStyles(doc, book, apiBase, body, ct);

        RewriteAnchors(page, doc, mappings);

        ScopeImages(doc, book, apiBase);

        // Inject PTOC Bookmark Icons
        InjectTextBookmarks(doc, ptocBookmarks);

        // Inject Annotations
        InjectAnnotations(doc, annotations);


        return PrepareFinalHtml(doc, body);
    }

    /// <summary>
    /// Tries to find the correct key by applying cleaning and remapping if the epub has bad data. Only works for HTML files.
    /// </summary>
    /// <param name="book"></param>
    /// <param name="mappings"></param>
    /// <param name="key"></param>
    /// <returns></returns>
    private static string? CoalesceKey(EpubBookRef book, IReadOnlyDictionary<string, int> mappings, string? key)
    {
        if (string.IsNullOrEmpty(key)) return key;
        if (mappings.ContainsKey(CleanContentKeys(key))) return key;

        // Fallback to searching for key (bad epub metadata)
        var correctedKey = book.Content.Html.Local.Select(s => s.Key).FirstOrDefault(s => s.EndsWith(key));
        if (!string.IsNullOrEmpty(correctedKey))
        {
            key = correctedKey;
        }

        var stepsBack = CountParentDirectory(book.Content.NavigationHtmlFile?.FilePath);
        if (mappings.TryGetValue(key, out _))
        {
            return key;
        }

        var modifiedKey = RemovePathSegments(key, stepsBack);
        if (mappings.TryGetValue(modifiedKey, out _))
        {
            return modifiedKey;
        }


        return key;
    }

    public static string CoalesceKeyForAnyFile(EpubBookRef book, string key)
    {
        if (book.Content.AllFiles.ContainsLocalFileRefWithKey(key)) return key;

        var cleanedKey = CleanContentKeys(key);
        if (book.Content.AllFiles.ContainsLocalFileRefWithKey(cleanedKey)) return cleanedKey;

        // Correct relative paths ./
        if (key.StartsWith("./"))
        {
            var nonPathKey = key.Replace("./", string.Empty);
            var correctedKey = book.Content.AllFiles.Local.SingleOrDefault(s => s.Key == nonPathKey);
            if (correctedKey != null) return correctedKey.Key;
        }

        return key;
    }

    /// <summary>
    /// This will return a list of mappings from ID -> page num. ID will be the xhtml key and page num will be the reading order
    /// this is used to rewrite anchors in the book text so that we always load properly in our reader.
    /// </summary>
    /// <param name="chapter">Chapter with at least one file</param>
    /// <param name="ct"></param>
    /// <returns></returns>
    public async Task<ICollection<BookChapterItem>> GenerateTableOfContents(Chapter chapter,
        CancellationToken ct = default)
    {
        using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, LenientBookReaderOptions);
        if (book == null) return [];

        var mappings = await CreateKeyToPageMappingAsync(book, ct);

        var navItems = await book.GetNavigationAsync();
        var chaptersList = new List<BookChapterItem>();

        if (navItems != null)
        {
            foreach (var navigationItem in navItems)
            {
                var tocItem = CreateToCChapter(book, navigationItem, mappings);
                if (tocItem != null)
                {
                    chaptersList.Add(tocItem);
                }
            }
        }

        if (chaptersList.Count != 0) return chaptersList;

        // Rest of your fallback logic remains the same...
        // Generate from TOC from links (any point past this, Kavita is generating as a TOC doesn't exist)
        var tocPage = book.Content.Html.Local.Select(s => s.Key)
            .FirstOrDefault(k => k.Equals("TOC.XHTML", StringComparison.InvariantCultureIgnoreCase) ||
            k.Equals("NAVIGATION.XHTML", StringComparison.InvariantCultureIgnoreCase));

        if (string.IsNullOrEmpty(tocPage)) return chaptersList;
        if (!book.Content.Html.TryGetLocalFileRefByKey(tocPage, out var file) || file == null) return chaptersList;
        var content = await file.ReadContentAsync();

        var doc = new HtmlDocument();
        doc.LoadHtml(content);

        var anchors = doc.DocumentNode.SelectNodes("//a");
        // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
        if (anchors == null) return chaptersList;

        foreach (var anchor in anchors)
        {
            if (!anchor.Attributes.Contains("href")) continue;

            var key = CoalesceKey(book, mappings, anchor.Attributes["href"].Value.Split("#")[0]);

            if (string.IsNullOrEmpty(key) || !mappings.ContainsKey(key)) continue;
            var part = string.Empty;
            if (anchor.Attributes["href"].Value.Contains('#'))
            {
                part = anchor.Attributes["href"].Value.Split("#")[1];
            }
            chaptersList.Add(new BookChapterItem
            {
                Title = anchor.InnerText,
                Page = mappings[key],
                Part = part,
                Children = []
            });
        }

        return chaptersList;
    }

    private static BookChapterItem? CreateToCChapter(EpubBookRef book, EpubNavigationItemRef navigationItem, Dictionary<string, int> mappings)
    {
        // Get the page mapping for the current navigation item
        var key = CoalesceKey(book, mappings, navigationItem.Link?.ContentFilePath);
        int? page = null;
        if (!string.IsNullOrEmpty(key) && mappings.TryGetValue(key, out var mapping))
        {
            page = mapping;
        }

        // Recursively process nested items
        var children = new List<BookChapterItem>();
        if (navigationItem.NestedItems?.Count > 0)
        {
            foreach (var nestedItem in navigationItem.NestedItems)
            {
                var childItem = CreateToCChapter(book, nestedItem, mappings);
                if (childItem != null)
                {
                    children.Add(childItem);
                }
            }
        }

        // Only create a BookChapterItem if we have a valid page or children
        if (page.HasValue || children.Count > 0)
        {
            return new BookChapterItem
            {
                Title = navigationItem.Title ?? string.Empty,
                Page = page ?? 0, // You might want to handle this differently
                Part = navigationItem.Link?.Anchor ?? string.Empty,
                Children = children
            };
        }

        return null;
    }

    private static int CountParentDirectory(string? path)
    {
        if (string.IsNullOrEmpty(path)) return 0;
        return ParentDirectoryRegex().Matches(path).Count;
    }

    /// <summary>
    /// Removes paths segments from the beginning of a path. Returns original path if any issues.
    /// </summary>
    /// <param name="path"></param>
    /// <param name="segmentsToRemove"></param>
    /// <returns></returns>
    private static string RemovePathSegments(string path, int segmentsToRemove)
    {
        if (segmentsToRemove <= 0)
            return path;

        var startIndex = 0;
        for (var i = 0; i < segmentsToRemove; i++)
        {
            var slashIndex = path.IndexOf('/', startIndex);
            if (slashIndex == -1)
                return path; // Not enough segments to remove

            startIndex = slashIndex + 1;
        }

        return path.Substring(startIndex);
    }

    public async Task<string> GetBookPage(int userId, int page, int chapterId, string cachedEpubPath, string baseUrl,
        List<PersonalToCDto> ptocBookmarks, List<AnnotationDto> annotations, CancellationToken ct = default)
    {
        var authKey = (await unitOfWork.UserRepository.GetAuthKeysForUserId(userId, ct))
            .First(k => k is { Name: AuthKeyHelper.ImageOnlyKeyName, Provider: AuthKeyProvider.System })
            .Key;

        using var book = await EpubReader.OpenBookAsync(cachedEpubPath, LenientBookReaderOptions);
        var mappings = await CreateKeyToPageMappingAsync(book, ct);
        var apiBase = baseUrl + "book/" + chapterId + "/" + string.Format(BookApiUrl, authKey);

        var counter = 0;
        var doc = new HtmlDocument {OptionFixNestedTags = true};


        var bookPages = await book.GetReadingOrderAsync();
        try
        {
            foreach (var contentFileRef in bookPages)
            {
                if (page != counter)
                {
                    counter++;
                    continue;
                }

                var content = await contentFileRef.ReadContentAsync();
                if (contentFileRef.ContentType != EpubContentType.XHTML_1_1) return content;

                // In more cases than not, due to this being XML not HTML, we need to escape the script tags.
                content = EscapeTags(content);

                doc.LoadHtml(content);


                var body = doc.DocumentNode.SelectSingleNode("//body");

                // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract
                if (body == null)
                {
                    if (doc.ParseErrors.Any())
                    {
                        LogBookErrors(book, contentFileRef, doc);
                        throw new KavitaException("epub-malformed");
                    }
                    logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
                    doc.DocumentNode.SelectSingleNode("/html").AppendChild(HtmlNode.CreateNode("<body></body>"));
                    body = doc.DocumentNode.SelectSingleNode("/html/body");
                }

                return await ScopePage(doc, book, apiBase, body!, mappings, page, ptocBookmarks, annotations, ct);
            }
        } catch (Exception ex)
        {
            logger.LogError(ex, "There was an issue reading one of the pages for {Book}", book.FilePath);
            await mediaErrorService.ReportMediaIssueAsync(book.FilePath ?? string.Empty, MediaErrorProducer.BookService,
                "There was an issue reading one of the pages for", ex, ct);
        }

        throw new KavitaException("epub-html-missing");
    }

    /// <summary>
    /// Extracts the cover image to covers directory and returns file path back
    /// </summary>
    /// <param name="fileFilePath"></param>
    /// <param name="fileName">Name of the new file.</param>
    /// <param name="outputDirectory">Where to output the file, defaults to covers directory</param>
    /// <param name="encodeFormat">When saving the file, use encoding</param>
    /// <returns></returns>
    public string GetCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default)
    {
        if (!IsValidFile(fileFilePath)) return string.Empty;

        if (Parser.IsPdf(fileFilePath))
        {
            return GetPdfCoverImage(fileFilePath, fileName, outputDirectory, encodeFormat, size);
        }

        using var epubBook = EpubReader.OpenBook(fileFilePath, LenientBookReaderOptions);
        if (epubBook == null) return string.Empty;

        try
        {
            // Try to get the cover image from OPF file, if not set, try to parse it from all the files, then result to the first one.
            var coverImageContent = epubBook.Content.Cover
                                    ?? epubBook.Content.Images.Local.FirstOrDefault(file => Parser.IsCoverImage(file.FilePath))
                                    ?? epubBook.Content.Images.Local.FirstOrDefault();

            if (coverImageContent == null) return string.Empty;
            using var stream = coverImageContent.GetContentStream();

            return imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size);
        }
        catch (Exception ex)
        {
            logger.LogWarning(ex, "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image", fileFilePath);
            mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
                "There was a critical error and prevented thumbnail generation", ex);
        }

        return string.Empty;
    }

    public static string? GetChapterTitleFromToC(ICollection<BookChapterItem>? tableOfContents, int pageNumber)
    {
        if (tableOfContents == null) return null;

        foreach (var item in tableOfContents)
        {
            // Check if current item matches the page number
            if (item.Page == pageNumber)
                return item.Title;

            // Recursively search children if they exist
            if (item.Children?.Count > 0)
            {
                var childResult = GetChapterTitleFromToC(item.Children, pageNumber);
                if (childResult != null)
                    return childResult;
            }
        }

        return null;
    }


    private string GetPdfCoverImage(string fileFilePath, string fileName, string outputDirectory, EncodeFormat encodeFormat, CoverImageSize size)
    {
        try
        {
            using var docReader = DocLib.Instance.GetDocReader(fileFilePath, new PageDimensions(1080, 1920));
            if (docReader.GetPageCount() == 0) return string.Empty;

            using var stream = StreamManager.GetStream("BookService.GetPdfPage");
            GetPdfPage(docReader, 0, stream);

            return imageService.WriteCoverThumbnail(stream, fileName, outputDirectory, encodeFormat, size);

        }
        catch (Exception ex)
        {
            logger.LogWarning(ex,
                "[BookService] There was a critical error and prevented thumbnail generation on {BookFile}. Defaulting to no cover image",
                fileFilePath);
            mediaErrorService.ReportMediaIssue(fileFilePath, MediaErrorProducer.BookService,
                "There was a critical error and prevented thumbnail generation", ex);
        }

        return string.Empty;
    }

    /// <summary>
    /// Returns an image raster of a page within a PDF
    /// </summary>
    /// <param name="docReader"></param>
    /// <param name="pageNumber"></param>
    /// <param name="stream"></param>
    private static void GetPdfPage(IDocReader docReader, int pageNumber, Stream stream)
    {
        using var pageReader = docReader.GetPageReader(pageNumber);
        var rawBytes = pageReader.GetImage(new NaiveTransparencyRemover());
        var width = pageReader.GetPageWidth();
        var height = pageReader.GetPageHeight();
        var image = Image.LoadPixelData<Bgra32>(rawBytes, width, height);

        stream.Seek(0, SeekOrigin.Begin);
        image.SaveAsPng(stream);
        stream.Seek(0, SeekOrigin.Begin);
    }

    private static string RemoveWhiteSpaceFromStylesheets(string body)
    {
        if (string.IsNullOrEmpty(body))
        {
            return string.Empty;
        }

        // Remove comments from CSS
        body = Regex.Replace(body, @"/\*[\d\D]*?\*/", string.Empty, RegexOptions.None, Parser.RegexTimeout);

        body = Regex.Replace(body, @"[a-zA-Z]+#", "#", RegexOptions.None, Parser.RegexTimeout);
        body = Regex.Replace(body, @"[\n\r]+\s*", string.Empty, RegexOptions.None, Parser.RegexTimeout);
        body = Regex.Replace(body, @"\s+", " ", RegexOptions.None, Parser.RegexTimeout);
        body = Regex.Replace(body, @"\s?([:,;{}])\s?", "$1", RegexOptions.None, Parser.RegexTimeout);

        // Handle <!-- which some books use (but shouldn't)
        body = Regex.Replace(body, "<!--.*?-->", string.Empty, RegexOptions.None, Parser.RegexTimeout);

        // Handle /* */
        body = Regex.Replace(body, @"/\*.*?\*/", string.Empty, RegexOptions.None, Parser.RegexTimeout);

        try
        {
            body = body.Replace(";}", "}");
        }
        catch (Exception)
        {
            //Swallow exception. Some css don't have style rules ending in ';'
        }

        body = Regex.Replace(body, @"([\s:]0)(px|pt|%|em)", "$1", RegexOptions.None, Parser.RegexTimeout);


        return body;
    }

    private void LogBookErrors(EpubBookRef book, EpubContentFileRef contentFileRef, HtmlDocument doc)
    {
        logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.Key);
        foreach (var error in doc.ParseErrors)
        {
            logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
        }
    }

    [GeneratedRegex(@"\.\./")]
    private static partial Regex ParentDirectoryRegex();
}
