// TODO: Anilist support?
// TODO: Properly avoid swallowing of input from UICollectionView used for scrolling
// TODO: Convert between state for normal and scrolling page turn

import FlatBuffers
import Foundation
import UIKit
import ZIPFoundation

let preloadCount = 2
/// With a whopping 2 cores, and 2 threads because no hyperthreading,
/// it makes sense to only have a queue as an extra thread to do work with to allow the main thread for ui.
let queue = DispatchQueue(label: "queue", qos: .utility)

enum PageTurn {
    case next
    case previous
}

enum ReadProgress: Codable {
    case leftToRight(volumeIndex: Int, chapterIndex: Int, imageIndex: Int)
    case rightToLeft(volumeIndex: Int, chapterIndex: Int, imageIndex: Int)
    case scroll(CGPoint)
}

struct ProgressIndices {
    var v: Int
    var c: Int
    var i: Int
}

enum PageTurnMode: Codable {
    case leftToRight
    case rightToLeft
    case scroll
}

struct Comic {
    var cover: UIImage
    var metadata: Metadata
    var path: URL
}

struct GlobalState: Codable {
    var comicName: String?
}

struct LocalState: Codable {
    var progress: ReadProgress
    var backgroundColor: String = "black"
}

final class ViewController: UIViewController, UIGestureRecognizerDelegate {
    let metadataFilename = "metadata.fb"

    var homeView = UIView()
    var readerView = UIView()

    @IBOutlet var scrollingCollectionView: UICollectionView!
    var scrollPos: CGPoint!
    var hasSetContentOffset = false

    var pendingAlert: UIAlertController?

    var initialBrightness: CGFloat = 0
    let dragRangeForFullBrightness: CGFloat = 300

    var imageView = UIImageView()
    var mode = PageTurnMode.leftToRight
    var metadataList: [URL: Metadata] = [:]
    var metadata: Metadata!
    var currentPage: Int!
    var progress = ProgressIndices(v: 0, c: 0, i: 0)
    var currentPath: URL!
    var lastLayoutSize: CGSize = .zero

    var leftTap: UITapGestureRecognizer!
    var rightTap: UITapGestureRecognizer!
    var topTap: UITapGestureRecognizer!
    let leftView = UIView()
    let rightView = UIView()
    let topView = UIView()

    let topBarView = UIView()
    let bottomBarView = UIView()
    let info = UILabel()

    let pageTurnDropdownView = UIView()
    let pageTurnDropdownButton = UIButton()

    let backgroundColorDropdownView = UIView()
    let backgroundColorDropdownButton = UIButton()

    let homeButton = UIButton()

    let fileManager = FileManager.default
    var globalState = getGlobalState()

    let documentsURL = getDocumentsURL().unsafelyUnwrapped

    var comics: [Comic] = []
    @IBOutlet var comicCollectionView: UICollectionView!

    let imageLoader = ImageLoader()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .white

        setup()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let alert = pendingAlert {
            present(alert, animated: false, completion: nil)
        }
    }

    func setup() {
        do {
            if try fileManager.contentsOfDirectory(atPath: documentsURL.path).isEmpty {
                saveGlobalState()
            }
        } catch {
            saveGlobalState()
        }

        homeView.translatesAutoresizingMaskIntoConstraints = false
        readerView.translatesAutoresizingMaskIntoConstraints = false
        readerView.isHidden = true
        readerView.backgroundColor = .black
        homeView.isHidden = false
        view.addSubview(homeView)
        view.addSubview(readerView)
        NSLayoutConstraint.activate([
            homeView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            homeView.topAnchor.constraint(equalTo: view.topAnchor),
            homeView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            homeView.widthAnchor.constraint(equalTo: view.widthAnchor),

            readerView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            readerView.topAnchor.constraint(equalTo: view.topAnchor),
            readerView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            readerView.widthAnchor.constraint(equalTo: view.widthAnchor),
        ])
        setupScrollingCollectionView()
        loadComics()
        setupImageView()
        setupGestures()
        setupBar()
        setupHomeView()
        if let name = globalState.comicName {
            readComic(name: name)
        }
    }

    func setupHomeView() {
        let layout = UICollectionViewFlowLayout()
        let spacing: CGFloat = 8
        layout.minimumInteritemSpacing = spacing
        layout.minimumLineSpacing = spacing

        comicCollectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout)
        comicCollectionView.translatesAutoresizingMaskIntoConstraints = false
        comicCollectionView.dataSource = self
        comicCollectionView.delegate = self
        comicCollectionView.backgroundColor = .white
        comicCollectionView.register(
            ComicImageCell.self, forCellWithReuseIdentifier: "ComicImageCell"
        )
        homeView.addSubview(comicCollectionView)
    }

    func loadComics() {
        let alert = UIAlertController(
            title: "Failed to load comics.",
            message: "This is an alert.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))
        do {
            var directories: [URL] = []

            guard let contents = try? fileManager.contentsOfDirectory(
                at: documentsURL,
                includingPropertiesForKeys: [.isDirectoryKey],
                options: [.skipsHiddenFiles]
            )
            else {
                alert.message = String(format: "Failed to read contents of directory: \(documentsURL)")
                pendingAlert = alert
                return
            }

            directories = contents.filter { url in
                var isDirectory: ObjCBool = false
                return fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory)
                    && isDirectory.boolValue
            }.sorted { $0.path < $1.path }

            for dir in directories {
                if !fileManager.fileExists(atPath: dir.appendingPathComponent(metadataFilename).path) {
                    getMetadataFromFilename(path: dir)
                    if pendingAlert != nil {
                        return
                    }
                }

                currentPath = dir

                let metadataPath = dir.appendingPathComponent(metadataFilename)
                guard let data = try? Data(contentsOf: metadataPath)
                else {
                    alert.message = String(format: "Failed to read contents of metadata: \(metadataPath))")
                    pendingAlert = alert
                    return
                }
                var byteBuffer = ByteBuffer(data: data)
                metadata = try? getCheckedRoot(byteBuffer: &byteBuffer)
                if metadata == nil {
                    alert.message =
                        String(format: "Failed to read contents of metadata flatbuffer file: \(metadataPath))")
                    pendingAlert = alert
                    return
                }
                metadataList[dir] = metadata
                loadLocalState()
                let volCovP = ProgressIndices(v: progress.v, c: 0, i: 0)
                let archivePath = getArchiveURL(volCovP.v)
                let archive = try Archive(url: archivePath, accessMode: .read)
                let filename = metadata.volumes[volCovP.v].chapters[volCovP.c].images[volCovP.i].filename!
                guard let targetEntry = archive[filename] else {
                    alert.message = String(format: "Archive entry does not exist: \(filename)")
                    pendingAlert = alert
                    return
                }
                var extractedData = Data()
                do {
                    _ = try archive.extract(targetEntry) { data in
                        extractedData.append(data)
                    }
                } catch {
                    alert.message = String(format: "Failed to extract data from entry: \(targetEntry.path)")
                    pendingAlert = alert
                    return
                }
                guard let coverImage = UIImage(data: extractedData) else {
                    alert.message = String(format: "Cover image is nil: \(filename)")
                    pendingAlert = alert
                    return
                }
                comics.append(
                    Comic(
                        cover: coverImage,
                        metadata: metadata,
                        path: dir
                    )
                )
            }
        } catch {
            pendingAlert = alert
            alert.message = String(format: "Generic failure.")
            print("Failed to read directories")
        }
    }

    func saveGlobalState() {
        do {
            try JSONEncoder().encode(globalState).write(
                to: documentsURL.appendingPathComponent("state.json")
            )
        } catch {
            print("failed to save global state")
        }
    }

    func convertColorToString(color: UIColor) -> String {
        let r: String!
        switch color {
        case .white: r = "white"
        case .gray: r = "gray"
        case .black: r = "black"
        case .red: r = "red"
        case .blue: r = "blue"
        default: r = "black"
        }
        return r
    }

    func convertStringToColor(str: String) -> UIColor {
        let r: UIColor!
        switch str {
        case "white": r = .white
        case "gray": r = .gray
        case "black": r = .black
        case "red": r = .red
        case "blue": r = .blue
        default: r = .black
        }
        return r
    }

    func imageSize(at url: URL) -> CGSize? {
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
            return nil
        }

        guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
              let width = properties[kCGImagePropertyPixelWidth] as? CGFloat,
              let height = properties[kCGImagePropertyPixelHeight] as? CGFloat
        else {
            return nil
        }

        return CGSize(width: width, height: height)
    }

    struct Pattern {
        var pattern: NSRegularExpression
        var title: NSRegularExpression
        var volumePosition: Int
        var chapterPosition: Int
        var pagePosition: Int
    }

    func getMetadataFromFilename(path: URL) {
        let alert = UIAlertController(
            title: "Failed to get metadata from filename.",
            message: "This is an alert.",
            preferredStyle: .alert
        )
        alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))

        // Beautiful, is it not?
        var nPatterns: [Pattern]?
        do {
            let fPatterns: [Pattern] = try [
                Pattern(
                    pattern: NSRegularExpression(
                        pattern: #"c([0-9]+(?:x[0-9]+)?) \(v([0-9]+)\) - p([0-9]+(?:-p?[0-9]+)?)"#
                    ),
                    title: NSRegularExpression(
                        pattern: #"^(.*) - c([0-9]+(?:x[0-9]+)?) \(v([0-9]+)\) - p([0-9]+(?:-p?[0-9]+)?)"#
                    ),
                    volumePosition: 2,
                    chapterPosition: 1,
                    pagePosition: 3
                ),
                Pattern(
                    pattern: NSRegularExpression(
                        pattern: "v([0-9]+) p([0-9]+(?:-[0-9]+)?) - c([0-9]+(?:x[0-9]+)?)"
                    ),
                    title: NSRegularExpression(
                        pattern: "^(.*) - v([0-9]+) p([0-9]+(?:-[0-9]+)?) - c([0-9]+(?:x[0-9]+)?)"
                    ),
                    volumePosition: 1,
                    chapterPosition: 3,
                    pagePosition: 2
                ),
            ]
            nPatterns = fPatterns
        } catch {
            alert.message = String(format: "Regular expression is invalid")
            pendingAlert = alert
            return
        }
        guard let patterns = nPatterns else {
            alert.message = String(format: "Regular expression is invalid")
            pendingAlert = alert
            return
        }

        if fileManager.fileExists(
            atPath: path.appendingPathComponent(metadataFilename).absoluteString
        ) {
            print("skipped metadata file creation")
            return
        }

        var builder = FlatBufferBuilder(initialSize: 1024)

        var volumes: [Offset] = []
        var currentChapters: [Offset] = []
        var currentImages: [Offset] = []
        var previousVolumeURL = ""

        var titleValue = ""

        var currentVolume: UInt32!
        var currentChapter: (UInt32, UInt32?)!

        let volumeDir = path.appendingPathComponent("volumes")

        var isDir: ObjCBool = false
        if !fileManager.fileExists(atPath: volumeDir.path, isDirectory: &isDir) || !isDir.boolValue {
            print("Subdirectory volumes not found in \"\(path)\", returning")
            return
        }
        let volumeURLs = try? fileManager.contentsOfDirectory(
            at: volumeDir,
            includingPropertiesForKeys: [.isRegularFileKey],
            options: [.skipsHiddenFiles]
        ).filter { $0.pathExtension == "cbz" }
            .sorted { $0.lastPathComponent < $1.lastPathComponent }
        if volumeURLs == nil {
            alert.message = String(format: "failed to read dir: \(volumeDir)")
            pendingAlert = alert
            return
        }

        if volumeURLs!.isEmpty {
            print("found no volumes in volume dir: \(volumeDir)")
            return
        }

        var filenames: [(String, String)] = []
        for volumeURL in volumeURLs! {
            do {
                let volumeArchive = try Archive(url: volumeURL, accessMode: .read)
                volumeArchive.map { $0.path }.sorted().forEach { filenames.append((volumeURL.lastPathComponent, $0)) }
            } catch {
                alert.message = String(format: "failed to read archive: \(volumeURL)")
                pendingAlert = alert
                continue
            }
        }

        if filenames.isEmpty {
            print("Found no files in archives")
            return
        }

        var matchingPattern: Pattern?
        do {
            for testPattern in patterns {
                let filename = filenames[0].1
                let range = NSRange(filename.startIndex..., in: filename)
                if let match = testPattern.title.firstMatch(in: filename, range: range) {
                    if (filename as NSString).substring(with: match.range(at: 1)) == "" {
                        continue
                    }
                } else {
                    continue
                }
                if let match = testPattern.pattern.firstMatch(in: filename, range: range) {
                    if (filename as NSString).substring(with: match.range(at: testPattern.pagePosition)) == "" {
                        continue
                    }
                } else {
                    continue
                }
                if let match = testPattern.pattern.firstMatch(in: filename, range: range) {
                    if (filename as NSString).substring(with: match.range(at: testPattern.chapterPosition)) == "" {
                        continue
                    }
                } else {
                    continue
                }
                if let match = testPattern.pattern.firstMatch(in: filename, range: range) {
                    if (filename as NSString).substring(with: match.range(at: testPattern.volumePosition)) == "" {
                        continue
                    }
                } else {
                    continue
                }
                matchingPattern = testPattern
                break
            }

            guard let pattern = matchingPattern else {
                alert.message = String(format: "Could not find matching pattern, likely missing regex.")
                pendingAlert = alert
                return
            }

            for (i, (volumeURL, filename)) in filenames.enumerated() {
                let range = NSRange(filename.startIndex..., in: filename)
                if i == 0 {
                    if let match = pattern.title.firstMatch(in: filename, range: range) {
                        titleValue = (filename as NSString).substring(with: match.range(at: 1))
                    } else {
                        print("Did not find match for title, regex did not handle this case.")
                        return
                    }
                }
                if let match = pattern.pattern.firstMatch(in: filename, range: range) {
                    let chapterStr = (filename as NSString).substring(with: match.range(at: pattern.chapterPosition))
                    let chapterParts = chapterStr.split(separator: "x", maxSplits: 1)
                    if chapterParts.count < 1 {
                        alert.message = String(format: "Did not find chapter number in filename: \(filename)")
                        pendingAlert = alert
                        return
                    }
                    guard let nChapter0 = UInt32(chapterParts[0]) else {
                        alert.message = String(format: "Failed to convert chapter number to an int: \(filename)")
                        pendingAlert = alert
                        return
                    }
                    let chapter: (UInt32, UInt32?) = (
                        nChapter0, chapterParts.count > 1 ? UInt32(chapterParts[1]) : nil
                    )

                    guard let volume =
                        UInt32((filename as NSString)
                                .substring(with: match.range(at: pattern.volumePosition)))
                    else {
                        alert.message = String(format: "failed to get match for volume in filename: \(filename)")
                        pendingAlert = alert
                        return
                    }

                    let pageStr = (filename as NSString).substring(with: match.range(at: pattern.pagePosition))
                    let pageParts = pageStr.split(separator: "-", maxSplits: 1)
                    guard let nPage = UInt32(pageParts[0]) else {
                        alert.message = String(format: "failed to read archive: \(filename)")
                        pendingAlert = alert
                        return
                    }
                    let (page, doublePage) = (
                        nPage, pageParts.count > 1
                    )

                    let archive = try Archive(url: volumeDir.appendingPathComponent(volumeURL), accessMode: .read)
                    guard let entry = archive[filename] else {
                        alert.message = String(format: "failed to read archive: \(filename)")
                        pendingAlert = alert
                        return
                    }
                    var size = ImageDimensions(width: 0, height: 0)
                    do {
                        size = try imageDimensions(of: entry, in: archive)
                    } catch {
                        alert.message = String(format: "Failed to get image dimensions of filename: \(filename)")
                        pendingAlert = alert
                        return
                    }
                    let m_filename = builder.create(string: filename)
                    let imageMetadata = ImageMetadata.createImageMetadata(
                        &builder,
                        doublePage: doublePage,
                        filenameOffset: m_filename,
                        firstPage: page,
                        size: Size(width: UInt32(size.width), height: UInt32(size.height))
                    )

                    if currentVolume != nil {
                        if volume != currentVolume {
                            assert(volume > currentVolume)

                            let chapterName = builder.create(string: "")
                            let imagesOffset = builder.createVector(ofOffsets: currentImages)
                            let chapterMetadata = ChapterMetadata.createChapterMetadata(
                                &builder,
                                chapter: MetaValue(
                                    main: currentChapter.0, bonus: currentChapter.1 ?? 0
                                ),
                                nameOffset: chapterName,
                                imagesVectorOffset: imagesOffset
                            )

                            currentChapters.append(chapterMetadata)

                            let volumeTitle = builder.create(string: "")
                            let volumeURLOffset = builder.create(string: previousVolumeURL)
                            let chaptersOffset = builder.createVector(ofOffsets: currentChapters)
                            let volumeMetadata = VolumeMetadata.createVolumeMetadata(
                                &builder,
                                volume: MetaValue(main: currentVolume, bonus: 0),
                                titleOffset: volumeTitle,
                                archiveOffset: volumeURLOffset,
                                chaptersVectorOffset: chaptersOffset
                            )

                            volumes.append(volumeMetadata)

                            currentImages = [imageMetadata]
                            currentChapters = []
                        } else if chapter != currentChapter {
                            if chapter.0 == currentChapter.0 {
                                if chapter.1 == nil {
                                    alert.message = String(format: "chapter.1 is nil")
                                    pendingAlert = alert
                                    return
                                }
                                if chapter.1! != currentChapter.1 ?? 1 {
                                    alert.message = String(format: "chapter != currentChapter")
                                    pendingAlert = alert
                                    return
                                }
                                assert(chapter.1! == currentChapter.1 ?? 1)
                            } else {
                                if chapter.0 != currentChapter.0 + 1 {
                                    alert.message = String(format: "chapter.0 != currentChapter.0 + 1")
                                    pendingAlert = alert
                                    return
                                }
                                assert(chapter.0 == currentChapter.0 + 1)
                            }

                            let chapterName = builder.create(string: "")
                            let imagesOffset = builder.createVector(ofOffsets: currentImages)
                            let chapterMetadata = ChapterMetadata.createChapterMetadata(
                                &builder,
                                chapter: MetaValue(
                                    main: currentChapter.0, bonus: currentChapter.1 ?? 0
                                ),
                                nameOffset: chapterName,
                                imagesVectorOffset: imagesOffset
                            )

                            currentChapters.append(chapterMetadata)

                            currentImages = [imageMetadata]

                        } else {
                            currentImages.append(imageMetadata)
                        }
                    } else {
                        currentImages.append(imageMetadata)
                    }
                    if i == filenames.count - 1 {
                        let chapterName = builder.create(string: "")
                        let imagesOffset = builder.createVector(ofOffsets: currentImages)
                        let chapterMetadata = ChapterMetadata.createChapterMetadata(
                            &builder,
                            chapter: MetaValue(
                                main: chapter.0, bonus: chapter.1 ?? 0
                            ),
                            nameOffset: chapterName,
                            imagesVectorOffset: imagesOffset
                        )

                        currentChapters.append(chapterMetadata)

                        let volumeTitle = builder.create(string: "")
                        let volumeURLOffset = builder.create(string: volumeURL)
                        let chaptersOffset = builder.createVector(ofOffsets: currentChapters)
                        let volumeMetadata = VolumeMetadata.createVolumeMetadata(
                            &builder,
                            volume: MetaValue(main: volume, bonus: 0),
                            titleOffset: volumeTitle,
                            archiveOffset: volumeURLOffset,
                            chaptersVectorOffset: chaptersOffset
                        )

                        volumes.append(volumeMetadata)

                        currentImages = []
                        currentChapters = []
                    }
                    currentVolume = volume
                    currentChapter = chapter
                    previousVolumeURL = volumeURL
                } else {
                    print("no regex match on \(filename), returning")
                    return
                }
            }
        } catch {
            print("failed reading image file names")
        }

        if volumes.count == 0 {
            print("Why no metadata from file name?")
            return
        }

        let title = builder.create(string: titleValue)
        let originalLanguage = builder.create(string: "Japanese")
        let countryOfOrigin = builder.create(string: "Japan")
        let description = builder.create(string: "")
        let tags = builder.createVector(ofOffsets: [])
        let volumeOffsets = builder.createVector(ofOffsets: volumes)
        let metadata = Metadata.createMetadata(
            &builder,
            titleOffset: title,
            format: Format.manga,
            originalLanguageOffset: originalLanguage,
            countryOfOriginOffset: countryOfOrigin,
            publicationDemographic: PublicationDemographic.shounen,
            status: Status.finished,
            contentRating: ContentRating.safe,
            source: Source.original,
            startReleaseDate: Date(year: 2000, month: 1, day: 1),
            endReleaseDate: Date(year: 2000, month: 1, day: 1),
            tagsVectorOffset: tags,
            descriptionOffset: description,
            volumesVectorOffset: volumeOffsets
        )

        builder.finish(offset: metadata)
        do {
            try builder.data.write(to: path.appendingPathComponent(metadataFilename))
        } catch {
            alert.message = String(format: "Failed to write metadata file at: \(metadataFilename)")
            pendingAlert = alert
        }
    }

    func saveLocalState() {
        let color = readerView.backgroundColor ?? .black
        let screenSize = UIScreen.main.bounds.size
        var scrollOffset = scrollingCollectionView.contentOffset
        if screenSize.width > screenSize.height {
            scrollOffset.y *= (screenSize.height / screenSize.width)
        }
        var newProgress =
            ReadProgress.leftToRight(
                volumeIndex: progress.v, chapterIndex: progress.c,
                imageIndex: progress.i
            )

        switch mode {
        case .leftToRight:
            newProgress = ReadProgress.leftToRight(
                volumeIndex: progress.v, chapterIndex: progress.c,
                imageIndex: progress.i
            )
        case .rightToLeft: newProgress = ReadProgress.rightToLeft(
                volumeIndex: progress.v, chapterIndex: progress.c,
                imageIndex: progress.i
            )
        case .scroll: newProgress = ReadProgress.scroll(scrollOffset)
        }
        queue.async {
            do {
                try JSONEncoder().encode(
                    LocalState(
                        progress: newProgress, backgroundColor: self.convertColorToString(color: color)
                    )
                ).write(
                    to: self.currentPath.appendingPathComponent("state.json")
                )
            } catch {
                print("failed to save local state")
            }
        }
    }

    func loadLocalState() {
        do {
            let path = currentPath.appendingPathComponent("state.json").path
            if !fileManager.fileExists(atPath: path) {
                progress = ProgressIndices(v: 0, c: 0, i: 0)
                mode = .leftToRight
                return
            }
            let json = try Data(String(contentsOfFile: path).utf8)
            let local = try JSONDecoder().decode(LocalState.self, from: json)
            switch local.progress {
            case let .leftToRight(volumeIndex, chapterIndex, imageIndex):
                progress.v = volumeIndex
                progress.c = chapterIndex
                progress.i = imageIndex
                mode = .leftToRight
            case let .rightToLeft(volumeIndex, chapterIndex, imageIndex):
                progress.v = volumeIndex
                progress.c = chapterIndex
                progress.i = imageIndex
                mode = .rightToLeft
            case let .scroll(point):
                if scrollPos == nil {
                    scrollPos = point
                    let screenSize = UIScreen.main.bounds.size
                    var scrollOffset = point
                    if screenSize.width > screenSize.height {
                        scrollOffset.y *= (screenSize.width / screenSize.height)
                    }
                    if let indexPath = scrollingCollectionView.indexPathForItem(at: scrollOffset) {
                        var theProgress = ProgressIndices(v: 0, c: 0, i: 0)
                        for _ in 0 ..< indexPath.item {
                            theProgress = getProgressIndicesFromTurn(
                                turn: .next, progress: theProgress
                            )
                        }
                        progress = theProgress
                    }
                }
                mode = .scroll
            }
            readerView.backgroundColor = convertStringToColor(str: local.backgroundColor)
        } catch let decodingError as DecodingError {
            print(decodingError.errorDescription!)
        } catch {
            print("Unexpected error: \(error)")
        }
    }

    func setupGestures() {
        let swipeLeft = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
        swipeLeft.direction = .left
        readerView.addGestureRecognizer(swipeLeft)

        let swipeRight = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipe(_:)))
        swipeRight.direction = .right
        readerView.addGestureRecognizer(swipeRight)

        let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
        pan.minimumNumberOfTouches = 2
        pan.maximumNumberOfTouches = 2
        readerView.addGestureRecognizer(pan)

        setupTapZones()
    }

    func scrollViewDidScroll(_: UIScrollView) {
        if scrollingCollectionView.isHidden { return }
        let centerPoint = CGPoint(
            x: scrollingCollectionView.bounds.midX + scrollingCollectionView.contentOffset.x,
            y: scrollingCollectionView.bounds.midY + scrollingCollectionView.contentOffset.y
        )

        if let indexPath = scrollingCollectionView.indexPathForItem(at: centerPoint) {
            var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
            for _ in 0 ..< indexPath.item {
                newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
            }
            progress = newProgress
        }
        if scrollingCollectionView.isHidden == false {
            saveLocalState()
        }
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        guard view.bounds.size != lastLayoutSize else { return }
        lastLayoutSize = UIScreen.main.bounds.size
        if scrollingCollectionView.isHidden == true
            || mode != .scroll
            || scrollingCollectionView.contentSize == .zero
        {
            if metadata == nil { return }
            imageLoader.loadImage(
                archiveURL: getArchiveURL(progress.v),
                filename: getImagePath(progress),
                scaling: .scaleAspectFit,
                screenSize: UIScreen.main.bounds.size
            ) { image in
                self.imageView.image = image
            }
            return
        }
        if !hasSetContentOffset, scrollPos != nil {
            let screenSize = UIScreen.main.bounds.size
            var offset = scrollPos!
            if screenSize.width > screenSize.height {
                offset.y *= (screenSize.width / screenSize.height)
            }
            scrollingCollectionView.setContentOffset(offset, animated: false)
            hasSetContentOffset = true
        } else {
            let screenSize = UIScreen.main.bounds.size
            var offset = scrollingCollectionView.contentOffset
            offset.y *= (screenSize.width / screenSize.height)
            scrollingCollectionView.setContentOffset(offset, animated: false)
        }
    }

    func readComic(name: String) {
        imageView.image = nil
        readerView.isHidden = false
        homeView.isHidden = true
        setNeedsStatusBarAppearanceUpdate()
        if let path = getPathFromComicName(name: name) {
            currentPath = path
            metadata = metadataList[path]

            globalState.comicName = metadata.title
            saveGlobalState()
            loadLocalState()
            scrollingCollectionView.isHidden = mode != .scroll
            leftView.isHidden = mode == .scroll
            rightView.isHidden = mode == .scroll
            imageView.isHidden = mode == .scroll
            if mode == .scroll {
                scrollingCollectionView.reloadData()
            } else {
                setImages()
            }
        } else {
            print("path not found")
        }
    }

    func setupScrollingCollectionView() {
        let layout = UICollectionViewFlowLayout()
        layout.minimumInteritemSpacing = 0
        layout.minimumLineSpacing = 0
        scrollingCollectionView = UICollectionView(
            frame: view.bounds, collectionViewLayout: layout
        )
        scrollingCollectionView.translatesAutoresizingMaskIntoConstraints = false
        scrollingCollectionView.dataSource = self
        scrollingCollectionView.delegate = self
        scrollingCollectionView.backgroundColor = .white
        scrollingCollectionView.isHidden = true
        scrollingCollectionView.register(
            ScrollingImageCell.self, forCellWithReuseIdentifier: "ScrollingImageCell"
        )
        readerView.addSubview(scrollingCollectionView)

        NSLayoutConstraint.activate([
            scrollingCollectionView.topAnchor.constraint(equalTo: readerView.topAnchor),
            scrollingCollectionView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            scrollingCollectionView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
            scrollingCollectionView.heightAnchor.constraint(equalTo: readerView.heightAnchor),
        ])
    }

    func setupTapZones() {
        leftView.translatesAutoresizingMaskIntoConstraints = false
        rightView.translatesAutoresizingMaskIntoConstraints = false
        topView.translatesAutoresizingMaskIntoConstraints = false

        readerView.addSubview(leftView)
        readerView.addSubview(rightView)
        readerView.addSubview(topView)

        NSLayoutConstraint.activate([
            leftView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            leftView.topAnchor.constraint(equalTo: readerView.topAnchor),
            leftView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
            leftView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),

            rightView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
            rightView.topAnchor.constraint(equalTo: readerView.topAnchor),
            rightView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
            rightView.widthAnchor.constraint(equalTo: readerView.widthAnchor, multiplier: 0.5),

            topView.topAnchor.constraint(equalTo: readerView.topAnchor),
            topView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            topView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
            topView.heightAnchor.constraint(equalTo: readerView.heightAnchor, multiplier: 0.2),
        ])

        leftView.backgroundColor = .clear
        rightView.backgroundColor = .clear
        topView.backgroundColor = .clear

        leftTap = UITapGestureRecognizer(target: self, action: #selector(handleLeftTap))
        rightTap = UITapGestureRecognizer(target: self, action: #selector(handleRightTap))
        topTap = UITapGestureRecognizer(target: self, action: #selector(handleTopTap))

        leftTap.delegate = self
        rightTap.delegate = self
        topTap.delegate = self

        leftView.addGestureRecognizer(leftTap)
        rightView.addGestureRecognizer(rightTap)
        topView.addGestureRecognizer(topTap)
    }

    func setupBar() {
        topBarView.translatesAutoresizingMaskIntoConstraints = false
        topBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
        topBarView.isHidden = true
        readerView.addSubview(topBarView)

        bottomBarView.translatesAutoresizingMaskIntoConstraints = false
        bottomBarView.backgroundColor = UIColor.black.withAlphaComponent(0.8)
        bottomBarView.isHidden = true
        readerView.addSubview(bottomBarView)

        NSLayoutConstraint.activate([
            topBarView.topAnchor.constraint(equalTo: readerView.topAnchor),
            topBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            topBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
            topBarView.heightAnchor.constraint(equalToConstant: 64),

            bottomBarView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
            bottomBarView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            bottomBarView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
            bottomBarView.heightAnchor.constraint(equalToConstant: 128),
        ])

        setupBackgroundColorDropdown()
        setupPageTurnDropdown()
        setupHomeButton()
        setupBottomBarInfo()
    }

    func setupBottomBarInfo() {
        info.textAlignment = .center
        info.numberOfLines = 6
        info.translatesAutoresizingMaskIntoConstraints = false
        info.textColor = .white
        bottomBarView.addSubview(info)
        NSLayoutConstraint.activate([
            info.topAnchor.constraint(equalTo: bottomBarView.topAnchor),
            info.bottomAnchor.constraint(equalTo: bottomBarView.bottomAnchor),
            info.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor),
            info.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor),
            info.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
            info.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
        ])
    }

    func setupHomeButton() {
        homeButton.setTitle("Home", for: .normal)
        homeButton.setTitleColor(.white, for: .normal)
        homeButton.translatesAutoresizingMaskIntoConstraints = false
        topBarView.addSubview(homeButton)

        homeButton.addTarget(
            self, action: #selector(goHome), for: .touchDown
        )
        NSLayoutConstraint.activate([
            homeButton.trailingAnchor.constraint(equalTo: topBarView.trailingAnchor, constant: -32),
            homeButton.centerYAnchor.constraint(equalTo: topBarView.centerYAnchor),
            homeButton.topAnchor.constraint(equalTo: topBarView.topAnchor),
            homeButton.bottomAnchor.constraint(equalTo: topBarView.bottomAnchor),
        ])
    }

    override var prefersStatusBarHidden: Bool {
        return homeView.isHidden
    }

    @objc func goHome() {
        readerView.isHidden = true
        homeView.isHidden = false
        globalState.comicName = nil
        hideBar()

        setNeedsStatusBarAppearanceUpdate()
        saveGlobalState()
    }

    func setupBackgroundColorDropdown() {
        backgroundColorDropdownButton.setTitle("Background color ▼", for: .normal)
        backgroundColorDropdownButton.setTitleColor(.white, for: .normal)
        backgroundColorDropdownButton.translatesAutoresizingMaskIntoConstraints = false
        topBarView.addSubview(backgroundColorDropdownButton)

        backgroundColorDropdownButton.addTarget(
            self, action: #selector(toggleBackgroundColorDropdown), for: .touchDown
        )

        backgroundColorDropdownView.backgroundColor = UIColor.darkGray
        backgroundColorDropdownView.translatesAutoresizingMaskIntoConstraints = false
        backgroundColorDropdownView.isHidden = true
        readerView.addSubview(backgroundColorDropdownView)

        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 8
        stackView.distribution = .fillEqually
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.isLayoutMarginsRelativeArrangement = true
        stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
        backgroundColorDropdownView.addSubview(stackView)

        let colorOptions = ["White", "Gray", "Black", "Red", "Blue"]
        for title in colorOptions {
            let button = UIButton(type: .system)
            button.setTitle(title, for: .normal)
            button.setTitleColor(.white, for: .normal)
            button.contentHorizontalAlignment = .left
            button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
            button.backgroundColor = .clear
            button.translatesAutoresizingMaskIntoConstraints = false
            button.addTarget(
                self, action: #selector(handleBackgroundColorOption), for: .touchDown
            )
            stackView.addArrangedSubview(button)
        }

        NSLayoutConstraint.activate([
            backgroundColorDropdownButton.leadingAnchor.constraint(
                equalTo: topBarView.leadingAnchor, constant: 32
            ),
            backgroundColorDropdownButton.centerYAnchor.constraint(
                equalTo: topBarView.centerYAnchor
            ),
            backgroundColorDropdownButton.topAnchor.constraint(
                equalTo: topBarView.topAnchor
            ),
            backgroundColorDropdownButton.bottomAnchor.constraint(
                equalTo: topBarView.bottomAnchor
            ),

            backgroundColorDropdownView.topAnchor.constraint(
                equalTo: backgroundColorDropdownButton.bottomAnchor
            ),
            backgroundColorDropdownView.leadingAnchor.constraint(
                equalTo: backgroundColorDropdownButton.leadingAnchor
            ),
            backgroundColorDropdownView.trailingAnchor.constraint(
                equalTo: backgroundColorDropdownButton.trailingAnchor
            ),

            stackView.topAnchor.constraint(
                equalTo: backgroundColorDropdownView.topAnchor
            ),
            stackView.bottomAnchor.constraint(equalTo: backgroundColorDropdownView.bottomAnchor),
            stackView.leadingAnchor.constraint(equalTo: backgroundColorDropdownView.leadingAnchor),
            stackView.trailingAnchor.constraint(
                equalTo: backgroundColorDropdownView.trailingAnchor
            ),
        ])
    }

    func setupPageTurnDropdown() {
        pageTurnDropdownButton.setTitle("Page Turn Mode ▼", for: .normal)
        pageTurnDropdownButton.setTitleColor(.white, for: .normal)
        pageTurnDropdownButton.translatesAutoresizingMaskIntoConstraints = false
        topBarView.addSubview(pageTurnDropdownButton)

        pageTurnDropdownButton.addTarget(
            self, action: #selector(togglePageTurnDropdown), for: .touchDown
        )

        pageTurnDropdownView.backgroundColor = UIColor.darkGray
        pageTurnDropdownView.translatesAutoresizingMaskIntoConstraints = false
        pageTurnDropdownView.isHidden = true
        readerView.addSubview(pageTurnDropdownView)

        let stackView = UIStackView()
        stackView.axis = .vertical
        stackView.spacing = 8
        stackView.distribution = .fillEqually
        stackView.translatesAutoresizingMaskIntoConstraints = false
        stackView.isLayoutMarginsRelativeArrangement = true
        stackView.layoutMargins = UIEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
        pageTurnDropdownView.addSubview(stackView)

        let pageTurnOptions = ["Left to right", "Right to left", "Scroll"]
        for title in pageTurnOptions {
            let button = UIButton(type: .system)
            button.setTitle(title, for: .normal)
            button.setTitleColor(.white, for: .normal)
            button.contentHorizontalAlignment = .left
            button.backgroundColor = .clear
            button.translatesAutoresizingMaskIntoConstraints = false
            button.contentEdgeInsets = UIEdgeInsets(top: 0, left: 8, bottom: 0, right: 0)
            button.addTarget(
                self, action: #selector(handlePageTurnOption), for: .touchDown
            )
            stackView.addArrangedSubview(button)
        }

        NSLayoutConstraint.activate([
            pageTurnDropdownButton.leadingAnchor.constraint(
                equalTo: backgroundColorDropdownButton.trailingAnchor, constant: 32
            ),
            pageTurnDropdownButton.centerYAnchor.constraint(
                equalTo: topBarView.centerYAnchor
            ),
            pageTurnDropdownButton.topAnchor.constraint(
                equalTo: topBarView.topAnchor
            ),
            pageTurnDropdownButton.bottomAnchor.constraint(
                equalTo: topBarView.bottomAnchor
            ),

            pageTurnDropdownView.topAnchor.constraint(
                equalTo: pageTurnDropdownButton.bottomAnchor
            ),
            pageTurnDropdownView.leadingAnchor.constraint(
                equalTo: pageTurnDropdownButton.leadingAnchor
            ),
            pageTurnDropdownView.trailingAnchor.constraint(
                equalTo: pageTurnDropdownButton.trailingAnchor
            ),

            stackView.topAnchor.constraint(
                equalTo: pageTurnDropdownView.topAnchor
            ),
            stackView.bottomAnchor.constraint(equalTo: pageTurnDropdownView.bottomAnchor),
            stackView.leadingAnchor.constraint(equalTo: pageTurnDropdownView.leadingAnchor),
            stackView.trailingAnchor.constraint(
                equalTo: pageTurnDropdownView.trailingAnchor
            ),
        ])
    }

    @objc func handlePageTurnOption(_ sender: UIButton) {
        if let title = sender.currentTitle {
            let prev = mode
            switch title.lowercased() {
            case "left to right": mode = .leftToRight
            case "right to left": mode = .rightToLeft
            case "scroll": mode = .scroll
            default: break
            }
            if prev == mode { return }
            if mode != .scroll {
                let archiveURL = getArchiveURL(progress.v)
                imageLoader.loadImage(
                    archiveURL: archiveURL,
                    filename: getImagePath(progress),
                    scaling: .scaleAspectFit,
                    screenSize: UIScreen.main.bounds.size
                ) { [weak self] image in
                    self?.imageView.image = image
                }
            }
        }
        scrollingCollectionView.isHidden = mode != .scroll
        leftView.isHidden = mode == .scroll
        rightView.isHidden = mode == .scroll
        imageView.isHidden = mode == .scroll
        // This does not optmize for the case where you switch between scrolling and non-scrolling in the same comic.
        // However this is badly supported because of the lack of state conversion.
        if mode == .scroll {
            scrollingCollectionView.reloadData()
        }
        togglePageTurnDropdown()
        saveLocalState()
    }

    @objc func handleBackgroundColorOption(_ sender: UIButton) {
        if let title = sender.currentTitle {
            switch title.lowercased() {
            case "white": readerView.backgroundColor = .white
            case "gray": readerView.backgroundColor = .gray
            case "black": readerView.backgroundColor = .black
            case "red": readerView.backgroundColor = .red
            case "blue": readerView.backgroundColor = .blue
            default: break
            }
        }
        toggleBackgroundColorDropdown()
        saveLocalState()
    }

    @objc func togglePageTurnDropdown() {
        pageTurnDropdownView.isHidden.toggle()
    }

    @objc func toggleBackgroundColorDropdown() {
        backgroundColorDropdownView.isHidden.toggle()
    }

    func gestureRecognizer(
        _: UIGestureRecognizer,
        shouldRecognizeSimultaneouslyWith _: UIGestureRecognizer
    ) -> Bool {
        return true
    }

    func toggleBar() {
        topBarView.isHidden.toggle()
        bottomBarView.isHidden.toggle()

        if topBarView.isHidden {
            pageTurnDropdownView.isHidden = true
            backgroundColorDropdownView.isHidden = true
        }
    }

    func scrollViewDidEndDecelerating(_: UIScrollView) {
        if scrollingCollectionView.isHidden { return }
        updateInfo()
        saveLocalState()
    }

    func scrollViewDidEndDragging(_: UIScrollView, willDecelerate decelerate: Bool) {
        if scrollingCollectionView.isHidden { return }
        if !decelerate {
            updateInfo()
        }
        saveLocalState()
    }

    func hideBar() {
        topBarView.isHidden = true
        bottomBarView.isHidden = true
        pageTurnDropdownView.isHidden = true
        backgroundColorDropdownView.isHidden = true
    }

    @objc func handleTopTap() {
        toggleBar()
    }

    @objc func handleLeftTap() {
        switch mode {
        case .rightToLeft: changeImage(turn: .next)
        case .leftToRight: changeImage(turn: .previous)
        case .scroll: break
        }
    }

    @objc func handleRightTap() {
        switch mode {
        case .rightToLeft: changeImage(turn: .previous)
        case .leftToRight: changeImage(turn: .next)
        case .scroll: break
        }
    }

    @objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
        switch gesture.direction {
        case .left:
            switch mode {
            case .rightToLeft: changeImage(turn: .previous)
            case .leftToRight: changeImage(turn: .next)
            case .scroll: break
            }
        case .right:
            switch mode {
            case .rightToLeft: changeImage(turn: .next)
            case .leftToRight: changeImage(turn: .previous)
            case .scroll: break
            }
        default: break
        }
    }

     @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
        switch gesture.state {
        case .began:
            initialBrightness = UIScreen.main.brightness
        case .changed:
            let translation = gesture.translation(in: view)
            let delta = -translation.y / dragRangeForFullBrightness
            let newBrightness = initialBrightness + delta
            UIScreen.main.brightness = min(max(newBrightness, 0.0), 1.0)
        default:
            break
        }
    }

    func setupImageView() {
        imageView.translatesAutoresizingMaskIntoConstraints = false
        // Scaling is done when the image is loaded to avoid scaling on main thread
        imageView.contentMode = .center
        imageView.clipsToBounds = true

        readerView.addSubview(imageView)

        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: readerView.topAnchor),
            imageView.bottomAnchor.constraint(equalTo: readerView.bottomAnchor),
            imageView.leadingAnchor.constraint(equalTo: readerView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: readerView.trailingAnchor),
        ])
    }

    func changeImage(turn: PageTurn) {
        let scaling = UIView.ContentMode.scaleAspectFit
        var newProgress = progress
        newProgress = getProgressIndicesFromTurn(turn: turn, progress: newProgress)
        if newProgress.v == progress.v, newProgress.c == progress.c, newProgress.i == progress.i {
            return
        }
        progress = newProgress
        updateInfo()
        let path = getImagePath(progress)

        imageLoader.setActiveWindow([path])

        imageLoader.loadImage(
            archiveURL: getArchiveURL(progress.v),
            filename: getImagePath(progress),
            scaling: scaling,
            screenSize: UIScreen.main.bounds.size
        ) { [weak self] image in
            self?.imageView.image = image
        }

        for _ in 0 ..< preloadCount {
            newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
            imageLoader.preloadImage(
                archiveURL: getArchiveURL(newProgress.v),
                filename: getImagePath(newProgress),
                scaling: scaling,
                screenSize: UIScreen.main.bounds.size
            )
        }

        saveLocalState()
    }

    func metaValueToString(m: MetaValue) -> String {
        var r = ""
        if m.bonus != 0 {
            r = String(m.main) + "." + String(m.bonus)
        } else {
            r = String(m.main)
        }
        return r
    }

    func updateInfo() {
        if metadata == nil { return }

        var text = "\(metadata.title ?? "")\n"

        if let chapterTitle = metadata.volumes[progress.v].chapters[progress.c].name {
            text +=
                "\(chapterTitle)\n"
        }
        let chapterValue = metadata.volumes[progress.v].chapters[progress.c].chapter!
        let chapterString = metaValueToString(m: chapterValue)

        let lastChapterValue = metadata
            .volumes[metadata.volumes.count - 1]
            .chapters[metadata.volumes[metadata.volumes.count - 1]
                .chapters.count - 1].chapter!
        let lastChapterString = metaValueToString(m: lastChapterValue)

        let volumeValue = metadata.volumes[progress.v].volume!
        let volumeString = metaValueToString(m: volumeValue)

        let lastVolumeValue = metadata.volumes[metadata.volumes.count - 1].volume!
        let lastVolumeString = metaValueToString(m: lastVolumeValue)

        let lastChapterIndex = metadata.volumes[progress.v].chapters.count - 1
        let lastImageIndex =
            metadata.volumes[progress.v].chapters[lastChapterIndex].images.count - 1

        let lastImage = metadata.volumes[progress.v].chapters[lastChapterIndex].images[
            lastImageIndex
        ]
        var lastPageString = ""
        if lastImage.doublePage {
            lastPageString = String(lastImage.firstPage + 1)
        } else {
            lastPageString = String(lastImage.firstPage)
        }

        let currentImage = metadata.volumes[progress.v].chapters[progress.c].images[progress.i]
        var pageString = ""
        if currentImage.doublePage {
            pageString = String(currentImage.firstPage) + "-" + String(currentImage.firstPage + 1)
        } else {
            pageString = String(currentImage.firstPage)
        }

        text +=
            """
            Volume \(volumeString) of \(lastVolumeString)
            Chapter \(chapterString) of \(lastChapterString)
            Page \(pageString) of \(lastPageString)
            """
        let imageSize = metadata.volumes[progress.v].chapters[progress.c].images[progress.i].size!
        text += "\nImage size: \(imageSize.width)x\(imageSize.height)"
        info.text = text
    }

    func getArchiveURL(_ volumeProgress: Int) -> URL {
        return currentPath
            .appendingPathComponent("volumes")
            .appendingPathComponent(metadata.volumes[volumeProgress].archive)
    }

    func getImagePath(_ progress: ProgressIndices) -> String {
        let (v, c, i) = (progress.v, progress.c, progress.i)
        return metadata.volumes[v].chapters[c].images[i].filename!
        // if fileManager.fileExists(atPath: modernPath.path) {
        // }
        // return nil
    }

    func getProgressIndicesFromTurn(turn: PageTurn, progress: ProgressIndices) -> ProgressIndices {
        let (v, c, i) = (progress.v, progress.c, progress.i)
        switch turn {
        case .next:
            if metadata.volumes[v].chapters[c].images.count > i + 1 {
                return ProgressIndices(v: v, c: c, i: i + 1)
            }
            if metadata.volumes[v].chapters.count > c + 1 {
                return ProgressIndices(v: v, c: c + 1, i: 0)
            }
            if metadata.volumes.count > v + 1 {
                return ProgressIndices(v: v + 1, c: 0, i: 0)
            }
        case .previous:
            if i > 0 {
                return ProgressIndices(v: v, c: c, i: i - 1)
            }
            if c > 0 {
                return ProgressIndices(
                    v: v, c: c - 1, i: metadata.volumes[v].chapters[c - 1].images.count - 1
                )
            }
            if v > 0 {
                return ProgressIndices(
                    v: v - 1, c: metadata.volumes[v - 1].chapters.count - 1,
                    i: metadata.volumes[v - 1].chapters[
                        metadata.volumes[v - 1].chapters.count - 1
                    ].images.count - 1
                )
            }
        }
        return ProgressIndices(v: v, c: c, i: i)
    }

    func getMetadata(path: URL) -> Metadata? {
        guard let data = try? Data(contentsOf: path.appendingPathComponent(metadataFilename)) else { return nil }
        var byteBuffer = ByteBuffer(data: data)
        guard let metadata: Metadata = try? getCheckedRoot(byteBuffer: &byteBuffer) else { return nil }
        return metadata
    }

    func setImages() {
        let scaling: UIView.ContentMode = mode == .scroll ? .scaleAspectFill : .scaleAspectFit
        imageLoader.loadImage(
            archiveURL: getArchiveURL(progress.v),
            filename: getImagePath(progress),
            scaling: scaling,
            screenSize: UIScreen.main.bounds.size
        ) { [weak self] image in
            self?.imageView.image = image
        }
        updateInfo()

        var newProgress = progress
        for _ in 0 ..< preloadCount {
            newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
            imageLoader.preloadImage(
                archiveURL: getArchiveURL(newProgress.v),
                filename: getImagePath(newProgress),
                scaling: scaling,
                screenSize: UIScreen.main.bounds.size
            )
        }
    }

    func getPathFromComicName(name: String) -> URL? {
        for comic in comics where comic.metadata.title == name {
            return comic.path
        }
        return nil
    }
}

func getGlobalState() -> GlobalState {
    let fileManager = FileManager.default
    if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
        let path = documentsURL.appendingPathComponent("state.json").path
        if !fileManager.fileExists(atPath: path) {
            return GlobalState(comicName: nil)
        }
        do {
            let json = try Data(
                String(
                    contentsOfFile: path
                ).utf8
            )
            return try JSONDecoder().decode(GlobalState.self, from: json)
        } catch {
            print("Error reading directory contents: \(error)")
        }
    }
    return GlobalState(comicName: nil)
}

func getDocumentsURL() -> URL? {
    if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        .first
    {
        return documentsURL
    }
    print("failed to get documents dir")
    return nil
}

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegateFlowLayout {
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection _: Int) -> Int {
        if collectionView == comicCollectionView {
            return comics.count
        } else if collectionView == scrollingCollectionView {
            if metadata == nil {
                return 0
            }
            var sum = 0
            for volume in metadata.volumes {
                for chapter in volume.chapters {
                    sum += chapter.images.count
                }
            }
            return sum
        }
        return 0
    }

    func collectionView(
        _ collectionView: UICollectionView,
        cellForItemAt indexPath: IndexPath
    ) -> UICollectionViewCell {
        if collectionView == comicCollectionView {
            guard let cell =
                collectionView.dequeueReusableCell(
                    withReuseIdentifier: "ComicImageCell", for: indexPath
                ) as? ComicImageCell
            else {
                return ComicImageCell()
            }
            cell.imageView.image = comics[indexPath.item].cover
            return cell
        } else if collectionView == scrollingCollectionView {
            let scaling = UIView.ContentMode.scaleAspectFill
            guard let cell =
                collectionView.dequeueReusableCell(
                    withReuseIdentifier: "ScrollingImageCell", for: indexPath
                )
                as? ScrollingImageCell
            else {
                return ScrollingImageCell()
            }
            if mode != .scroll { return cell }
            if metadata == nil {
                print("metadata is nil, should probably not be the case")
                return cell
            }
            var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
            if indexPath.item != 0 {
                for _ in 0 ..< indexPath.item {
                    newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
                }
            }
            imageLoader.loadImage(
                archiveURL: getArchiveURL(progress.v),
                filename: getImagePath(newProgress),
                scaling: scaling, screenSize: UIScreen.main.bounds.size
            ) { image in
                cell.imageView.image = image
            }
            return cell
        } else {
            assertionFailure()
        }

        // Xcode profiling sucks:
        return ComicImageCell()
    }

    func collectionView(
        _ collectionView: UICollectionView,
        layout _: UICollectionViewLayout,
        sizeForItemAt indexPath: IndexPath
    ) -> CGSize {
        if collectionView == comicCollectionView {
            let spacing: CGFloat = 8
            let itemsPerRow: CGFloat = 3
            let totalSpacing = (itemsPerRow - 1) * spacing
            let width = (collectionView.bounds.width - totalSpacing) / itemsPerRow
            return CGSize(width: width, height: width)
        } else if collectionView == scrollingCollectionView {
            if metadata == nil {
                return CGSize()
            }
            var newProgress = ProgressIndices(v: 0, c: 0, i: 0)
            for _ in 0 ..< indexPath.item {
                newProgress = getProgressIndicesFromTurn(turn: .next, progress: newProgress)
            }
            let imageMetadata = metadata.volumes[newProgress.v].chapters[newProgress.c].images[newProgress.i]
            return CGSize(
                width: readerView.bounds.width,
                height: CGFloat(imageMetadata.size!.height)
                    * readerView.bounds.width
                    / CGFloat(imageMetadata.size!.width)
            )
        } else {
            assertionFailure()
        }
        // Xcode profiling sucks:
        return CGSize()
    }

    func collectionView(
        _ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath
    ) {
        if collectionView == comicCollectionView {
            let selectedComic = comics[indexPath.item]

            readComic(name: selectedComic.metadata.title!)
        }
    }
}

class ScrollingImageCell: UICollectionViewCell {
    let imageView = UIImageView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        // Scaling is done when the image is loaded to avoid scaling on main thread
        imageView.contentMode = .center
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(imageView)

        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        ])
    }

    @available(*, unavailable)
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

class ComicImageCell: UICollectionViewCell {
    let imageView = UIImageView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        imageView.contentMode = .scaleAspectFit
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(imageView)

        NSLayoutConstraint.activate([
            imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
            imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
            imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
        ])
    }

    @available(*, unavailable)
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

enum Rotation {
    case vertical
    case horizontal
}

class ImageLoader {
    private let cache = NSCache<NSString, UIImage>()
    private let portraitFitCache = NSCache<NSString, UIImage>()
    private let landscapeFitCache = NSCache<NSString, UIImage>()
    private let portraitFillCache = NSCache<NSString, UIImage>()
    private let landscapeFillCache = NSCache<NSString, UIImage>()
    private var loadingTasks: [String: (UIImage?) -> Void] = [:]
    private var activeTokens: [String: CancellationToken] = [:]
    private let archives = NSCache<NSString, Archive>()

    init() {
        // 128 MiB
        cache.totalCostLimit = 128 * 1024 * 1024
        portraitFitCache.totalCostLimit = 128 * 1024 * 1024
        landscapeFitCache.totalCostLimit = 128 * 1024 * 1024
        portraitFillCache.totalCostLimit = 128 * 1024 * 1024
        landscapeFillCache.totalCostLimit = 128 * 1024 * 1024
        // The reason archives are cached and have a strict limit of four,
        // is because Archive supposedly opens a posix file pointer and keeps it until the object is destroyed.
        archives.countLimit = 4
    }

    func preloadImage(archiveURL: URL, filename: String, scaling: UIView.ContentMode, screenSize: CGSize) {
        loadImage(archiveURL: archiveURL, filename: filename, scaling: scaling, screenSize: screenSize, completion: nil)
    }

    func loadImage(
        archiveURL: URL, filename: String,
        scaling: UIView.ContentMode,
        screenSize: CGSize,
        completion: ((UIImage?) -> Void)?
    ) {
        let rotation: Rotation = screenSize.width > screenSize.height ? .horizontal : .vertical
        let key = filename

        if scaling == .scaleAspectFit {
            if rotation == .vertical, let cached = portraitFitCache.object(forKey: key as NSString) {
                completion?(cached); return
            }
            if rotation == .horizontal, let cached = landscapeFitCache.object(forKey: key as NSString) {
                completion?(cached); return
            }
        } else if scaling == .scaleAspectFill {
            if rotation == .vertical, let cached = portraitFillCache.object(forKey: key as NSString) {
                completion?(cached); return
            }
            if rotation == .horizontal, let cached = landscapeFillCache.object(forKey: key as NSString) {
                completion?(cached); return
            }
        } else { assertionFailure() }

        if let completion = completion {
            loadingTasks[key] = completion
        }
        if let existing = activeTokens[key], !existing.isCancelled {
            return
        }

        let token = CancellationToken()
        activeTokens[key] = token

        queue.async { [weak self] in
            guard let self = self else { return }

            func finish() {
                DispatchQueue.main.async {
                    guard self.activeTokens[key] === token else { return }
                    self.activeTokens.removeValue(forKey: key)
                    self.loadingTasks.removeValue(forKey: key)
                }
            }
            func setImage(_ image: UIImage) {
                DispatchQueue.main.async {
                    if let callback = self.loadingTasks[key] {
                        callback(image)
                    }
                }
            }

            if token.isCancelled { finish(); return }

            func extractData() -> Data? {
                var extractedData = Data()
                if self.archives.object(forKey: archiveURL.lastPathComponent as NSString) == nil {
                    do {
                        let archive = try Archive(url: archiveURL, accessMode: .read)
                        self.archives.setObject(archive, forKey: archiveURL.lastPathComponent as NSString)
                    } catch { return nil }
                }
                let archive = self.archives.object(forKey: archiveURL.lastPathComponent as NSString)!
                _ = try? archive.extract(archive[filename]!) { data in
                    extractedData.append(data)
                }
                return extractedData
            }

            if token.isCancelled { finish(); return }

            guard let image = cache.object(forKey: key as NSString) ?? UIImage(data: extractData()!) else {
                finish()
                return
            }

            self.cache.setObject(image, forKey: filename as NSString, cost: imageByteSize(image)!)

            if token.isCancelled { finish(); return }

            let scaledImage = scaleAndCacheImage(
                image: image,
                orientation: rotation,
                screenSize: screenSize,
                scaling: scaling,
                filename: key
            )

            if token.isCancelled { finish(); return }

            setImage(scaledImage)

            let otherOrientation: Rotation = rotation == .vertical ? .horizontal : .vertical

            _ = scaleAndCacheImage(
                image: image,
                orientation: otherOrientation,
                screenSize: screenSize,
                scaling: scaling,
                filename: key
            )
            finish()
        }
    }

    func scaleAndCacheImage(
        image: UIImage,
        orientation: Rotation,
        screenSize: CGSize,
        scaling: UIView.ContentMode,
        filename: String
    ) -> UIImage {
        let resizeTo = if orientation == .vertical {
            CGSize(
                width: min(screenSize.width, screenSize.height),
                height: max(screenSize.height, screenSize.width)
            )
        } else {
            CGSize(
                width: max(screenSize.width, screenSize.height),
                height: min(screenSize.height, screenSize.width)
            )
        }
        let scaledSize = aspectSize(for: image.size, in: resizeTo, scaling: scaling)
        let scaledImage = resizeImage(image, to: scaledSize)
        let cost = imageByteSize(scaledImage)!
        switch (orientation, scaling) {
        case (.vertical, .scaleAspectFit):
            portraitFitCache.setObject(scaledImage, forKey: filename as NSString, cost: cost)
        case (.vertical, .scaleAspectFill):
            portraitFillCache.setObject(scaledImage, forKey: filename as NSString, cost: cost)
        case (.horizontal, .scaleAspectFit):
            landscapeFitCache.setObject(scaledImage, forKey: filename as NSString, cost: cost)
        case (.horizontal, .scaleAspectFill):
            landscapeFillCache.setObject(scaledImage, forKey: filename as NSString, cost: cost)
        default:
            assertionFailure()
        }
        return scaledImage
    }

    func setActiveWindow(_ paths: [String]) {
        let desired = Set(paths)
        for (key, token) in activeTokens where !desired.contains(key) {
            token.cancel()
            loadingTasks[key] = nil
        }
    }
}

func imageByteSize(_ image: UIImage) -> Int? {
    guard let cgImage = image.cgImage else { return nil }
    return cgImage.bytesPerRow * cgImage.height
}

func resizeImage(_ image: UIImage, to size: CGSize) -> UIImage {
    UIGraphicsBeginImageContextWithOptions(size, true, 0.0)
    image.draw(in: CGRect(origin: .zero, size: size))
    let scaledImage = UIGraphicsGetImageFromCurrentImageContext()
    UIGraphicsEndImageContext()
    return scaledImage!
}

func aspectSize(for imageSize: CGSize, in boundingSize: CGSize, scaling: UIView.ContentMode) -> CGSize {
    let widthRatio = boundingSize.width / imageSize.width
    let heightRatio = boundingSize.height / imageSize.height
    var scale = CGFloat()
    switch scaling {
    case .scaleAspectFit: scale = min(widthRatio, heightRatio)
    case .scaleAspectFill: scale = max(widthRatio, heightRatio)
    default: scale = 1
    }

    return CGSize(
        width: imageSize.width * scale,
        height: imageSize.height * scale
    )
}

final class CancellationToken {
    private var _isCancelled = false
    private let lock = NSLock()

    var isCancelled: Bool {
        lock.lock(); defer { lock.unlock() }
        return _isCancelled
    }

    func cancel() {
        lock.lock(); defer { lock.unlock() }
        _isCancelled = true
    }
}

struct ImageDimensions {
    let width: Int
    let height: Int
}

enum ImageSizeResult: Error {
    case found(ImageDimensions)
}

enum ImageSizeError: Error {
    case couldNotDetermineSize
}

func imageDimensions(of entry: Entry, in archive: Archive) throws -> ImageDimensions {
    let imageSource = CGImageSourceCreateIncremental(nil)
    var accumulatedData = Data()

    do {
        // This runs until the closure throws, or the entry is fully consumed
        _ = try archive.extract(entry) { chunk in
            accumulatedData.append(chunk)
            CGImageSourceUpdateData(imageSource, accumulatedData as CFData, false)

            if let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
               let width = properties[kCGImagePropertyPixelWidth as String] as? Int,
               let height = properties[kCGImagePropertyPixelHeight as String] as? Int
            {
                // We have what we need — abort the rest of the decompression
                throw ImageSizeResult.found(ImageDimensions(width: width, height: height))
            }
        }
    } catch let ImageSizeResult.found(dimensions) {
        return dimensions
    }

    // Fallback: some formats only report size once fully loaded (rare, but be safe)
    CGImageSourceUpdateData(imageSource, accumulatedData as CFData, true)
    if let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any],
       let width = properties[kCGImagePropertyPixelWidth as String] as? Int,
       let height = properties[kCGImagePropertyPixelHeight as String] as? Int
    {
        print("This fell back to loading the complete image for size metadata")
        return ImageDimensions(width: width, height: height)
    }

    throw ImageSizeError.couldNotDetermineSize
}
