import Foundation

struct EpisodePlan: Identifiable {
    let id = UUID()
    let source: URL
    let sourceName: String
    let targetFolderName: String
    let tracks: [TrackPlan]
    let warnings: [String]
}

struct TrackPlan: Identifiable {
    let id = UUID()
    let source: URL
}

struct TonUinoExportOptions {
    enum TrackMode {
        case merged
        case separate
    }

    let overwriteExisting: Bool
    let writeReport: Bool
    let trackMode: TrackMode
}

struct TonUinoScanner {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func scan(inputURL: URL) throws -> [EpisodePlan] {
        let directoryURLs = try fileManager
            .contentsOfDirectory(
                at: inputURL,
                includingPropertiesForKeys: [.isDirectoryKey, .isHiddenKey],
                options: [.skipsHiddenFiles]
            )
            .filter { url in
                (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
            }
            .sortedByLocalizedName()

        let playableDirectories = directoryURLs.compactMap { directoryURL -> (URL, [URL])? in
            let trackURLs = (try? audioFiles(in: directoryURL)) ?? []
            guard !trackURLs.isEmpty else { return nil }
            return (directoryURL, trackURLs)
        }

        return playableDirectories.enumerated().map { index, item in
            let directoryURL = item.0
            let trackURLs = item.1
            let targetFolderName = String(format: "%02d", index + 1)
            var warnings: [String] = []

            if index >= 99 {
                warnings.append("TonUINO nutzt üblicherweise maximal 99 Ordner")
            }

            if trackURLs.count > 255 {
                warnings.append("Mehr als 255 Tracks in einem Ordner")
            }

            let nonMP3Count = trackURLs.filter { $0.pathExtension.lowercased() != "mp3" }.count
            if nonMP3Count > 0 {
                warnings.append("\(nonMP3Count) Datei(en) sind nicht MP3 und werden nicht exportiert")
            }

            let tracks = trackURLs.map { trackURL in
                TrackPlan(source: trackURL)
            }

            return EpisodePlan(
                source: directoryURL,
                sourceName: directoryURL.lastPathComponent,
                targetFolderName: targetFolderName,
                tracks: tracks,
                warnings: warnings
            )
        }
    }

    private func audioFiles(in directoryURL: URL) throws -> [URL] {
        try fileManager
            .contentsOfDirectory(
                at: directoryURL,
                includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey],
                options: [.skipsHiddenFiles]
            )
            .filter { url in
                let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
                return isFile && SupportedAudioExtensions.contains(url.pathExtension)
            }
            .sortedByLocalizedName()
    }
}

struct TonUinoExporter {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func export(
        episodes: [EpisodePlan],
        outputURL: URL,
        options: TonUinoExportOptions
    ) -> [ExportMessage] {
        var messages: [ExportMessage] = []
        var exportedEpisodes = 0
        var exportedEpisodePlans: [EpisodePlan] = []

        do {
            try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true)
        } catch {
            return [.error("Output-Ordner konnte nicht erstellt werden: \(error.localizedDescription)")]
        }

        for episode in episodes {
            do {
                switch options.trackMode {
                case .merged:
                    try exportMergedEpisode(episode, outputURL: outputURL, overwriteExisting: options.overwriteExisting)
                    messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3"))
                case .separate:
                    try exportSeparateEpisode(episode, outputURL: outputURL, overwriteExisting: options.overwriteExisting)
                    messages.append(.info("\(episode.sourceName) -> \(episode.targetFolderName)/001.mp3...\(String(format: "%03d.mp3", episode.tracks.count))"))
                }

                exportedEpisodes += 1
                exportedEpisodePlans.append(episode)
            } catch {
                messages.append(.error("\(episode.sourceName): \(error.localizedDescription)"))
            }
        }

        if options.writeReport {
            messages.append(contentsOf: ExportReportWriter(fileManager: fileManager).write(
                episodes: exportedEpisodePlans,
                outputURL: outputURL,
                trackMode: options.trackMode
            ))
        }

        messages.insert(.info("\(exportedEpisodes) Folge(n) exportiert."), at: 0)
        return messages
    }

    private func exportMergedEpisode(
        _ episode: EpisodePlan,
        outputURL: URL,
        overwriteExisting: Bool
    ) throws {
        let targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory)
        let destination = targetDirectory.appending(path: "001.mp3")
        let temporaryDestination = targetDirectory.appending(path: ".001.mp3.tmp")

        try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true)
        try prepareDestination(destination, overwriteExisting: overwriteExisting)

        if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
            try fileManager.removeItem(at: temporaryDestination)
        }

        do {
            try MP3Concatenator(fileManager: fileManager).concatenate(
                sources: episode.tracks.map(\.source),
                destination: temporaryDestination
            )

            if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
                try fileManager.removeItem(at: destination)
            }
            try fileManager.moveItem(at: temporaryDestination, to: destination)
        } catch {
            if fileManager.fileExists(atPath: temporaryDestination.path(percentEncoded: false)) {
                try? fileManager.removeItem(at: temporaryDestination)
            }
            throw error
        }
    }

    private func exportSeparateEpisode(
        _ episode: EpisodePlan,
        outputURL: URL,
        overwriteExisting: Bool
    ) throws {
        let targetDirectory = outputURL.appending(path: episode.targetFolderName, directoryHint: .isDirectory)
        try fileManager.createDirectory(at: targetDirectory, withIntermediateDirectories: true)

        for (index, track) in episode.tracks.enumerated() {
            guard track.source.pathExtension.lowercased() == "mp3" else {
                throw MP3Concatenator.ConcatenationError.unsupportedFormat(track.source.lastPathComponent)
            }

            let destination = targetDirectory.appending(path: String(format: "%03d.mp3", index + 1))
            try prepareDestination(destination, overwriteExisting: overwriteExisting)
            try fileManager.copyItem(at: track.source, to: destination)
        }
    }

    private func prepareDestination(_ destination: URL, overwriteExisting: Bool) throws {
        if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
            guard overwriteExisting else {
                throw ExportError.destinationExists(destination.lastPathComponent)
            }

            try fileManager.removeItem(at: destination)
        }
    }

    enum ExportError: LocalizedError {
        case destinationExists(String)

        var errorDescription: String? {
            switch self {
            case .destinationExists(let fileName):
                "Existiert bereits: \(fileName)"
            }
        }
    }
}

struct ExportReportWriter {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func write(
        episodes: [EpisodePlan],
        outputURL: URL,
        trackMode: TonUinoExportOptions.TrackMode
    ) -> [ExportMessage] {
        let destination = outputURL.appending(path: "TonUinoNator-Report.txt")
        var lines: [String] = [
            "TonUinoNator Exportbericht",
            "Erstellt: \(DateFormatter.reportDateTime.string(from: Date()))",
            "",
            "Folgen:"
        ]

        for episode in episodes {
            switch trackMode {
            case .merged:
                lines.append("\(episode.targetFolderName)/001.mp3 <- \(episode.sourceName)")
                for track in episode.tracks {
                    lines.append("  - \(track.source.lastPathComponent)")
                }
            case .separate:
                lines.append("\(episode.targetFolderName)/ <- \(episode.sourceName)")
                for (index, track) in episode.tracks.enumerated() {
                    lines.append("  - \(String(format: "%03d.mp3", index + 1)) <- \(track.source.lastPathComponent)")
                }
            }
        }

        do {
            try fileManager.createDirectory(at: outputURL, withIntermediateDirectories: true)
            try lines.joined(separator: "\n").write(to: destination, atomically: true, encoding: .utf8)
            return [.info("Exportbericht geschrieben: TonUinoNator-Report.txt")]
        } catch {
            return [.error("Exportbericht konnte nicht geschrieben werden: \(error.localizedDescription)")]
        }
    }
}

enum SupportedAudioExtensions {
    private static let values: Set<String> = ["mp3", "m4a", "aac", "wav", "flac", "ogg"]

    static func contains(_ pathExtension: String) -> Bool {
        values.contains(pathExtension.lowercased())
    }
}

struct MP3Concatenator {
    enum ConcatenationError: LocalizedError {
        case noSources
        case unsupportedFormat(String)
        case unreadableFile(String)

        var errorDescription: String? {
            switch self {
            case .noSources:
                "Keine MP3-Dateien zum Zusammenfügen gefunden."
            case .unsupportedFormat(let fileName):
                "\(fileName) ist keine MP3-Datei."
            case .unreadableFile(let fileName):
                "\(fileName) konnte nicht gelesen werden."
            }
        }
    }

    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func concatenate(sources: [URL], destination: URL) throws {
        guard !sources.isEmpty else {
            throw ConcatenationError.noSources
        }

        for source in sources where source.pathExtension.lowercased() != "mp3" {
            throw ConcatenationError.unsupportedFormat(source.lastPathComponent)
        }

        fileManager.createFile(atPath: destination.path(percentEncoded: false), contents: nil)
        guard let output = try? FileHandle(forWritingTo: destination) else {
            throw ConcatenationError.unreadableFile(destination.lastPathComponent)
        }

        defer {
            try? output.close()
        }

        for source in sources {
            guard let data = try? Data(contentsOf: source) else {
                throw ConcatenationError.unreadableFile(source.lastPathComponent)
            }

            let payload = stripMP3Metadata(from: data)
            try output.write(contentsOf: payload)
        }
    }

    private func stripMP3Metadata(from data: Data) -> Data {
        guard !data.isEmpty else { return data }

        let start = id3v2EndOffset(in: data)
        let end = id3v1StartOffset(in: data)

        guard start < end else { return Data() }
        return data.subdata(in: start..<end)
    }

    private func id3v2EndOffset(in data: Data) -> Int {
        guard data.count >= 10 else { return 0 }

        let header = [UInt8](data.prefix(10))
        guard header[0] == 0x49, header[1] == 0x44, header[2] == 0x33 else {
            return 0
        }

        let size = (Int(header[6] & 0x7F) << 21)
            | (Int(header[7] & 0x7F) << 14)
            | (Int(header[8] & 0x7F) << 7)
            | Int(header[9] & 0x7F)

        let footerSize = (header[5] & 0x10) == 0x10 ? 10 : 0
        return min(data.count, 10 + size + footerSize)
    }

    private func id3v1StartOffset(in data: Data) -> Int {
        guard data.count >= 128 else { return data.count }

        let start = data.count - 128
        let tag = data[start..<(start + 3)]
        if tag.elementsEqual([0x54, 0x41, 0x47]) {
            return start
        }

        return data.count
    }
}

struct SDCardCleaner {
    func clean(url: URL) -> [ExportMessage] {
        guard let dotCleanURL = dotCleanExecutableURL() else {
            return [.error("dot_clean wurde auf diesem Mac nicht gefunden.")]
        }

        let process = Process()
        process.executableURL = dotCleanURL
        process.arguments = [url.path(percentEncoded: false)]

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        do {
            try process.run()
            process.waitUntilExit()
        } catch {
            return [.error("dot_clean konnte nicht gestartet werden: \(error.localizedDescription)")]
        }

        let output = outputPipe.readString()
        let errorOutput = errorPipe.readString()
        var messages: [ExportMessage] = []

        if process.terminationStatus == 0 {
            messages.append(.info("SD-Karte bereinigt: \(url.lastPathComponent)"))
        } else {
            messages.append(.error("dot_clean beendet mit Code \(process.terminationStatus)."))
        }

        if !output.isEmpty {
            messages.append(.info(output))
        }

        if !errorOutput.isEmpty {
            messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput))
        }

        return messages
    }

    private func dotCleanExecutableURL() -> URL? {
        let candidates = [
            "/usr/sbin/dot_clean",
            "/usr/bin/dot_clean"
        ]

        return candidates
            .map(URL.init(fileURLWithPath:))
            .first { FileManager.default.isExecutableFile(atPath: $0.path(percentEncoded: false)) }
    }
}

struct MP3GainNormalizer {
    struct Options {
        enum GainMode: Hashable {
            case track
            case album
        }

        let gainMode: GainMode
        let preventClipping: Bool
        let dbOffset: String

        var arguments: [String] {
            var result: [String] = []

            switch gainMode {
            case .track:
                result.append("-r")
            case .album:
                result.append("-a")
            }

            if preventClipping {
                result.append("-k")
            }

            let trimmedOffset = dbOffset.trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmedOffset.isEmpty {
                result.append(contentsOf: ["-d", trimmedOffset.replacingOccurrences(of: ",", with: ".")])
            }

            return result
        }

        var commandDescription: String {
            (["mp3gain"] + arguments).joined(separator: " ")
        }
    }

    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func normalize(rootURL: URL, options: Options) -> [ExportMessage] {
        guard let mp3gainURL = mp3gainExecutableURL() else {
            return [.error("mp3gain wurde nicht gefunden. Erwartet z. B. /opt/homebrew/bin/mp3gain.")]
        }

        let validationMessages = validate(options: options)
        if !validationMessages.isEmpty {
            return validationMessages
        }

        do {
            let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
            guard !mp3Files.isEmpty else {
                return [.warning("Keine MP3-Dateien für MP3Gain gefunden.")]
            }

            var messages: [ExportMessage] = []
            var processed = 0

            for batch in mp3Files.chunked(into: 50) {
                let result = runMP3Gain(executableURL: mp3gainURL, options: options, files: batch)
                messages.append(contentsOf: result.messages)
                if result.success {
                    processed += batch.count
                }
            }

            messages.insert(.info("MP3Gain abgeschlossen: \(processed) von \(mp3Files.count) Datei(en) verarbeitet."), at: 0)
            return messages
        } catch {
            return [.error("MP3Gain-Dateisuche fehlgeschlagen: \(error.localizedDescription)")]
        }
    }

    private func validate(options: Options) -> [ExportMessage] {
        let trimmedOffset = options.dbOffset.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmedOffset.isEmpty else {
            return []
        }

        guard Double(trimmedOffset.replacingOccurrences(of: ",", with: ".")) != nil else {
            return [.error("dB-Offset muss eine Zahl sein, z. B. 4 oder -2.5.")]
        }

        return []
    }

    private func runMP3Gain(
        executableURL: URL,
        options: Options,
        files: [URL]
    ) -> (success: Bool, messages: [ExportMessage]) {
        let process = Process()
        process.executableURL = executableURL
        process.arguments = options.arguments + files.map { $0.path(percentEncoded: false) }

        let outputPipe = Pipe()
        let errorPipe = Pipe()
        process.standardOutput = outputPipe
        process.standardError = errorPipe

        do {
            try process.run()
            process.waitUntilExit()
        } catch {
            return (false, [.error("mp3gain konnte nicht gestartet werden: \(error.localizedDescription)")])
        }

        let output = outputPipe.readString()
        let errorOutput = errorPipe.readString()
        var messages: [ExportMessage] = []

        if process.terminationStatus == 0 {
            messages.append(.info("MP3Gain Batch verarbeitet: \(files.count) Datei(en)"))
        } else {
            messages.append(.error("MP3Gain beendet mit Code \(process.terminationStatus)."))
        }

        if !output.isEmpty {
            messages.append(.info(output))
        }

        if !errorOutput.isEmpty {
            messages.append(process.terminationStatus == 0 ? .warning(errorOutput) : .error(errorOutput))
        }

        return (process.terminationStatus == 0, messages)
    }

    private func mp3gainExecutableURL() -> URL? {
        let candidates = [
            "/opt/homebrew/bin/mp3gain",
            "/usr/local/bin/mp3gain",
            "/usr/bin/mp3gain"
        ]

        return candidates
            .map(URL.init(fileURLWithPath:))
            .first { fileManager.isExecutableFile(atPath: $0.path(percentEncoded: false)) }
    }
}

struct SDCardValidator {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func validate(rootURL: URL) -> [ExportMessage] {
        var messages: [ExportMessage] = []

        do {
            let items = try fileManager.contentsOfDirectory(
                at: rootURL,
                includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isHiddenKey],
                options: []
            ).sortedByLocalizedName()

            let visibleItems = items.filter { !$0.lastPathComponent.hasPrefix(".") }
            let metadataItems = items.filter { isMacMetadataFile($0.lastPathComponent) }
            if !metadataItems.isEmpty {
                messages.append(.warning("\(metadataItems.count) macOS-Metadaten-Datei(en) gefunden. dot_clean ausführen."))
            }

            let numericFolders = visibleItems.filter { url in
                (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
                    && isNumericTonUinoFolder(url.lastPathComponent)
            }

            if numericFolders.isEmpty {
                messages.append(.warning("Keine TonUINO-Folgenordner 01 bis 99 gefunden."))
            }

            for item in visibleItems {
                let values = try item.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
                let name = item.lastPathComponent

                if values.isDirectory == true {
                    validateRootDirectory(name: name, url: item, messages: &messages)
                } else if values.isRegularFile == true && name != "TonUinoNator-Report.txt" {
                    messages.append(.warning("Unerwartete Datei im Hauptordner: \(name)"))
                }
            }
        } catch {
            return [.error("SD-Karte konnte nicht gelesen werden: \(error.localizedDescription)")]
        }

        if messages.containsErrors {
            messages.insert(.error("Prüfung abgeschlossen: Fehler gefunden."), at: 0)
        } else if messages.containsWarnings {
            messages.insert(.warning("Prüfung abgeschlossen: Warnungen gefunden."), at: 0)
        } else {
            messages.insert(.info("Prüfung abgeschlossen: TonUINO-Struktur sieht gut aus."), at: 0)
        }

        return messages
    }

    private func validateRootDirectory(name: String, url: URL, messages: inout [ExportMessage]) {
        if isNumericTonUinoFolder(name) {
            validateEpisodeFolder(url: url, messages: &messages)
            return
        }

        if name.lowercased() == "mp3" || name.lowercased() == "advert" {
            validateFourDigitFolder(url: url, messages: &messages)
            return
        }

        messages.append(.warning("Unerwarteter Ordner im Hauptordner: \(name)"))
    }

    private func validateEpisodeFolder(url: URL, messages: inout [ExportMessage]) {
        do {
            let files = try fileManager.contentsOfDirectory(
                at: url,
                includingPropertiesForKeys: [.isRegularFileKey],
                options: []
            ).sortedByLocalizedName()

            let visibleFiles = files.filter { !$0.lastPathComponent.hasPrefix(".") }
            if visibleFiles.isEmpty {
                messages.append(.warning("\(url.lastPathComponent): leerer Ordner"))
            }

            for file in files where isMacMetadataFile(file.lastPathComponent) {
                messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)"))
            }

            for file in visibleFiles {
                let name = file.lastPathComponent
                let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
                guard isFile else {
                    messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)"))
                    continue
                }

                guard isThreeDigitMP3(name) else {
                    messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 001.mp3 bis 255.mp3"))
                    continue
                }
            }

            if visibleFiles.count > 255 {
                messages.append(.error("\(url.lastPathComponent): mehr als 255 Dateien"))
            }
        } catch {
            messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden"))
        }
    }

    private func validateFourDigitFolder(url: URL, messages: inout [ExportMessage]) {
        do {
            let files = try fileManager.contentsOfDirectory(
                at: url,
                includingPropertiesForKeys: [.isRegularFileKey],
                options: []
            ).sortedByLocalizedName()

            for file in files where isMacMetadataFile(file.lastPathComponent) {
                messages.append(.warning("\(url.lastPathComponent): macOS-Metadaten-Datei \(file.lastPathComponent)"))
            }

            for file in files where !file.lastPathComponent.hasPrefix(".") {
                let name = file.lastPathComponent
                let isFile = (try? file.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
                guard isFile else {
                    messages.append(.warning("\(url.lastPathComponent): unerwarteter Unterordner \(name)"))
                    continue
                }

                if !isFourDigitMP3(name) {
                    messages.append(.error("\(url.lastPathComponent): ungültiger Dateiname \(name), erwartet 0001.mp3 bis 9999.mp3"))
                }
            }
        } catch {
            messages.append(.error("\(url.lastPathComponent): konnte nicht gelesen werden"))
        }
    }

    private func isMacMetadataFile(_ name: String) -> Bool {
        name == ".DS_Store" || name.hasPrefix("._")
    }

    private func isNumericTonUinoFolder(_ name: String) -> Bool {
        guard name.count == 2, let number = Int(name), number >= 1, number <= 99 else {
            return false
        }

        return name.allSatisfy(\.isNumber)
    }

    private func isThreeDigitMP3(_ name: String) -> Bool {
        guard name.lowercased().hasSuffix(".mp3"), name.count >= 7 else { return false }
        let prefix = String(name.prefix(3))
        guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else {
            return false
        }

        return number >= 1 && number <= 255
    }

    private func isFourDigitMP3(_ name: String) -> Bool {
        guard name.lowercased().hasSuffix(".mp3"), name.count >= 8 else { return false }
        let prefix = String(name.prefix(4))
        guard prefix.allSatisfy(\.isNumber), let number = Int(prefix) else {
            return false
        }

        return number >= 1 && number <= 9999
    }
}

enum ID3TagField: Hashable {
    case title
    case artist
    case album
    case track
    case year
    case genre
    case comment
    case artwork

    var frameIDs: Set<String> {
        switch self {
        case .title: ["TIT2"]
        case .artist: ["TPE1", "TPE2", "TCOM"]
        case .album: ["TALB"]
        case .track: ["TRCK", "TPOS"]
        case .year: ["TYER", "TDRC", "TDAT", "TIME"]
        case .genre: ["TCON"]
        case .comment: ["COMM"]
        case .artwork: ["APIC", "PIC"]
        }
    }
}

struct ID3TagEditOptions {
    let removeAllTags: Bool
    let fieldsToRemove: Set<ID3TagField>
    let commentToWrite: String?

    var frameIDsToRemove: Set<String> {
        fieldsToRemove.reduce(into: Set<String>()) { result, field in
            result.formUnion(field.frameIDs)
        }
    }
}

struct ID3TagEditor {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func apply(to rootURL: URL, options: ID3TagEditOptions) -> [ExportMessage] {
        do {
            let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
            guard !mp3Files.isEmpty else {
                return [.warning("Keine MP3-Dateien gefunden.")]
            }

            var changed = 0
            var messages: [ExportMessage] = []

            for file in mp3Files {
                do {
                    let didChange = try edit(fileURL: file, options: options)
                    if didChange {
                        changed += 1
                    }
                } catch {
                    messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)"))
                }
            }

            messages.insert(.info("\(changed) von \(mp3Files.count) MP3-Datei(en) bearbeitet."), at: 0)
            return messages
        } catch {
            return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")]
        }
    }

    private func edit(fileURL: URL, options: ID3TagEditOptions) throws -> Bool {
        let original = try Data(contentsOf: fileURL)
        let edited = try ID3TagDataEditor(options: options).edit(data: original)

        guard edited != original else {
            return false
        }

        let temporaryURL = fileURL.deletingLastPathComponent()
            .appending(path: ".\(fileURL.lastPathComponent).tmp")

        if fileManager.fileExists(atPath: temporaryURL.path(percentEncoded: false)) {
            try fileManager.removeItem(at: temporaryURL)
        }

        try edited.write(to: temporaryURL, options: .atomic)
        try fileManager.removeItem(at: fileURL)
        try fileManager.moveItem(at: temporaryURL, to: fileURL)
        return true
    }
}

struct ID3TagInspector {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func inspect(rootURL: URL) -> [ExportMessage] {
        do {
            let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
            guard !mp3Files.isEmpty else {
                return [.warning("Keine MP3-Dateien gefunden.")]
            }

            var messages: [ExportMessage] = [.info("\(mp3Files.count) MP3-Datei(en) gefunden.")]

            for file in mp3Files {
                do {
                    let summary = try inspect(fileURL: file)
                    messages.append(.info(summary))
                } catch {
                    messages.append(.error("\(file.lastPathComponent): \(error.localizedDescription)"))
                }
            }

            return messages
        } catch {
            return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")]
        }
    }

    private func inspect(fileURL: URL) throws -> String {
        let data = try Data(contentsOf: fileURL)
        let id3v2 = ID3v2Tag(data: data)
        let id3v1 = ID3v1Tag(data: data)

        var parts: [String] = [fileURL.lastPathComponent]

        if let id3v2 {
            parts.append("ID3v2.\(id3v2.majorVersion)")
            let values = ID3ReadableTags(frames: id3v2.frames)
            parts.append(contentsOf: values.summaryParts)
        }

        if let id3v1 {
            parts.append("ID3v1")
            parts.append(contentsOf: id3v1.summaryParts)
        }

        if parts.count == 1 {
            parts.append("keine ID3-Tags")
        }

        return parts.joined(separator: " | ")
    }
}

struct ID3TagSearcher {
    private let fileManager: FileManager

    init(fileManager: FileManager) {
        self.fileManager = fileManager
    }

    func search(rootURL: URL, query: String) -> [ExportMessage] {
        let normalizedQuery = query.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)

        do {
            let mp3Files = try MP3FileFinder(fileManager: fileManager).mp3Files(in: rootURL)
            guard !mp3Files.isEmpty else {
                return [.warning("Keine MP3-Dateien gefunden.")]
            }

            var matches: [ExportMessage] = []

            for file in mp3Files {
                do {
                    let searchableValues = try searchableTags(in: file)
                    let matchingValues = searchableValues.filter { value in
                        value.text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current)
                            .contains(normalizedQuery)
                    }

                    if !matchingValues.isEmpty {
                        let fields = matchingValues
                            .map { "\($0.label): \($0.text)" }
                            .joined(separator: " | ")
                        matches.append(.info("\(fields) | Datei: \(file.lastPathComponent) | Ordner: \(file.deletingLastPathComponent().path(percentEncoded: false))"))
                    }
                } catch {
                    matches.append(.error("\(file.lastPathComponent): \(error.localizedDescription)"))
                }
            }

            if matches.isEmpty {
                return [.warning("Keine Titel, Alben oder Künstler gefunden für: \(query)")]
            }

            matches.insert(.info("\(matches.count) Treffer für: \(query)"), at: 0)
            return matches
        } catch {
            return [.error("Ordner konnte nicht gelesen werden: \(error.localizedDescription)")]
        }
    }

    private func searchableTags(in fileURL: URL) throws -> [(label: String, text: String)] {
        let data = try Data(contentsOf: fileURL)
        var values: [(label: String, text: String)] = []

        if let id3v2 = ID3v2Tag(data: data) {
            let readableTags = ID3ReadableTags(frames: id3v2.frames)
            values.append(contentsOf: readableTags.searchableValues)
        }

        if let id3v1 = ID3v1Tag(data: data) {
            if !id3v1.title.isEmpty {
                values.append(("Titel", id3v1.title))
            }
            if !id3v1.album.isEmpty {
                values.append(("Album", id3v1.album))
            }
            if !id3v1.artist.isEmpty {
                values.append(("Künstler", id3v1.artist))
            }
        }

        return values
    }
}

private struct MP3FileFinder {
    let fileManager: FileManager

    func mp3Files(in rootURL: URL) throws -> [URL] {
        let values = try rootURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])

        if values.isRegularFile == true {
            return rootURL.pathExtension.lowercased() == "mp3" ? [rootURL] : []
        }

        guard values.isDirectory == true else {
            return []
        }

        guard let enumerator = fileManager.enumerator(
            at: rootURL,
            includingPropertiesForKeys: [.isRegularFileKey, .isHiddenKey],
            options: [.skipsHiddenFiles]
        ) else {
            return []
        }

        return enumerator.compactMap { item in
            guard let url = item as? URL, url.pathExtension.lowercased() == "mp3" else {
                return nil
            }

            let isFile = (try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
            return isFile ? url : nil
        }
        .sortedByLocalizedName()
    }
}

private struct ID3TagDataEditor {
    enum EditError: LocalizedError {
        case unsupportedID3Version(Int)

        var errorDescription: String? {
            switch self {
            case .unsupportedID3Version(let version):
                "ID3v2.\(version) wird nicht unterstützt."
            }
        }
    }

    let options: ID3TagEditOptions

    func edit(data: Data) throws -> Data {
        let id3v2 = ID3v2Tag(data: data)
        let audioStart = id3v2?.endOffset ?? 0
        let audioEnd = id3v1StartOffset(in: data)
        let audioData = data.subdata(in: audioStart..<max(audioStart, audioEnd))

        if options.removeAllTags {
            let frames = options.commentToWrite.map { [ID3Frame.comment(text: $0, majorVersion: 3)] } ?? []
            return buildData(frames: frames, audioData: audioData, majorVersion: 3)
        }

        let majorVersion = id3v2?.majorVersion ?? 3
        guard majorVersion == 3 || majorVersion == 4 else {
            throw EditError.unsupportedID3Version(majorVersion)
        }

        var frames = id3v2?.frames ?? []
        let frameIDsToRemove = options.frameIDsToRemove
        frames.removeAll { frameIDsToRemove.contains($0.id) }

        if let comment = options.commentToWrite, !comment.isEmpty {
            frames.removeAll { $0.id == "COMM" }
            frames.append(.comment(text: comment, majorVersion: majorVersion))
        }

        if frames.isEmpty {
            return audioData
        }

        return buildData(frames: frames, audioData: audioData, majorVersion: majorVersion)
    }

    private func buildData(frames: [ID3Frame], audioData: Data, majorVersion: Int) -> Data {
        guard !frames.isEmpty else {
            return audioData
        }

        var frameData = Data()
        for frame in frames {
            frameData.append(frame.encoded(majorVersion: majorVersion))
        }

        var result = Data()
        result.append(contentsOf: [0x49, 0x44, 0x33])
        result.append(UInt8(majorVersion))
        result.append(0x00)
        result.append(0x00)
        result.append(contentsOf: syncsafeBytes(frameData.count))
        result.append(frameData)
        result.append(audioData)
        return result
    }

    private func id3v1StartOffset(in data: Data) -> Int {
        guard data.count >= 128 else { return data.count }

        let start = data.count - 128
        let tag = data[start..<(start + 3)]
        return tag.elementsEqual([0x54, 0x41, 0x47]) ? start : data.count
    }
}

private struct ID3v2Tag {
    let majorVersion: Int
    let endOffset: Int
    let frames: [ID3Frame]

    init?(data: Data) {
        guard data.count >= 10 else { return nil }

        let header = [UInt8](data.prefix(10))
        guard header[0] == 0x49, header[1] == 0x44, header[2] == 0x33 else {
            return nil
        }

        majorVersion = Int(header[3])
        let size = syncsafeInteger(header[6...9])
        let footerSize = (header[5] & 0x10) == 0x10 ? 10 : 0
        endOffset = min(data.count, 10 + size + footerSize)

        guard majorVersion == 3 || majorVersion == 4, endOffset >= 10 else {
            frames = []
            return
        }

        frames = ID3Frame.parseFrames(
            data: data.subdata(in: 10..<endOffset),
            majorVersion: majorVersion
        )
    }
}

private struct ID3Frame {
    let id: String
    let payload: Data
    let flags: [UInt8]

    static func parseFrames(data: Data, majorVersion: Int) -> [ID3Frame] {
        var frames: [ID3Frame] = []
        var offset = 0

        while offset + 10 <= data.count {
            let header = [UInt8](data[offset..<(offset + 10)])
            if header[0] == 0 {
                break
            }

            guard let id = String(bytes: header[0..<4], encoding: .ascii),
                  id.allSatisfy({ $0.isLetter || $0.isNumber }) else {
                break
            }

            let size: Int
            if majorVersion == 4 {
                size = syncsafeInteger(header[4...7])
            } else {
                size = bigEndianInteger(header[4...7])
            }

            guard size >= 0, offset + 10 + size <= data.count else {
                break
            }

            let payload = data.subdata(in: (offset + 10)..<(offset + 10 + size))
            frames.append(ID3Frame(id: id, payload: payload, flags: Array(header[8...9])))
            offset += 10 + size
        }

        return frames
    }

    static func comment(text: String, majorVersion: Int) -> ID3Frame {
        var payload = Data()
        payload.append(0x03)
        payload.append(contentsOf: [0x64, 0x65, 0x75])
        payload.append(0x00)
        payload.append(Data(text.utf8))
        return ID3Frame(id: "COMM", payload: payload, flags: [0x00, 0x00])
    }

    func encoded(majorVersion: Int) -> Data {
        var data = Data()
        data.append(Data(id.utf8.prefix(4)))
        if majorVersion == 4 {
            data.append(contentsOf: syncsafeBytes(payload.count))
        } else {
            data.append(contentsOf: bigEndianBytes(payload.count))
        }
        data.append(contentsOf: flags.prefix(2))
        if flags.count < 2 {
            data.append(contentsOf: Array(repeating: UInt8(0), count: 2 - flags.count))
        }
        data.append(payload)
        return data
    }
}

private struct ID3ReadableTags {
    let frames: [ID3Frame]

    var searchableValues: [(label: String, text: String)] {
        var values: [(label: String, text: String)] = []
        appendSearchValue("Titel", ids: ["TIT2"], to: &values)
        appendSearchValue("Album", ids: ["TALB"], to: &values)
        appendSearchValue("Künstler", ids: ["TPE1", "TPE2", "TCOM"], to: &values)
        return values
    }

    var summaryParts: [String] {
        var parts: [String] = []

        appendText("Titel", ids: ["TIT2"], to: &parts)
        appendText("Interpret", ids: ["TPE1"], to: &parts)
        appendText("Album", ids: ["TALB"], to: &parts)
        appendText("Track", ids: ["TRCK"], to: &parts)
        appendText("Jahr", ids: ["TDRC", "TYER"], to: &parts)
        appendText("Genre", ids: ["TCON"], to: &parts)
        appendComment(to: &parts)
        appendArtwork(to: &parts)

        let shownIDs = Set(["TIT2", "TPE1", "TALB", "TRCK", "TDRC", "TYER", "TCON", "COMM", "APIC", "PIC"])
        let otherCount = frames.filter { !shownIDs.contains($0.id) }.count
        if otherCount > 0 {
            parts.append("\(otherCount) weitere Frame(s)")
        }

        if parts.isEmpty && !frames.isEmpty {
            parts.append("\(frames.count) nicht lesbare Frame(s)")
        }

        return parts
    }

    private func appendText(_ label: String, ids: Set<String>, to parts: inout [String]) {
        guard let text = textValue(ids: ids), !text.isEmpty else {
            return
        }

        parts.append("\(label): \(text)")
    }

    private func textValue(ids: Set<String>) -> String? {
        frames.first(where: { ids.contains($0.id) })?.textValue
    }

    private func appendSearchValue(_ label: String, ids: Set<String>, to values: inout [(label: String, text: String)]) {
        guard let text = textValue(ids: ids), !text.isEmpty else {
            return
        }

        values.append((label, text))
    }

    private func appendComment(to parts: inout [String]) {
        guard let comment = frames.first(where: { $0.id == "COMM" })?.commentValue, !comment.isEmpty else {
            return
        }

        parts.append("Kommentar: \(comment)")
    }

    private func appendArtwork(to parts: inout [String]) {
        let artworkCount = frames.filter { $0.id == "APIC" || $0.id == "PIC" }.count
        guard artworkCount > 0 else {
            return
        }

        parts.append("\(artworkCount) eingebettete(s) Bild(er)")
    }
}

private struct ID3v1Tag {
    let title: String
    let artist: String
    let album: String
    let year: String
    let comment: String
    let track: UInt8?
    let genre: UInt8

    init?(data: Data) {
        guard data.count >= 128 else { return nil }

        let start = data.count - 128
        let tag = data[start..<(start + 3)]
        guard tag.elementsEqual([0x54, 0x41, 0x47]) else {
            return nil
        }

        title = ID3v1Tag.trimmedString(data[(start + 3)..<(start + 33)])
        artist = ID3v1Tag.trimmedString(data[(start + 33)..<(start + 63)])
        album = ID3v1Tag.trimmedString(data[(start + 63)..<(start + 93)])
        year = ID3v1Tag.trimmedString(data[(start + 93)..<(start + 97)])

        let commentRange = data[(start + 97)..<(start + 127)]
        let commentBytes = [UInt8](commentRange)
        if commentBytes.count == 30, commentBytes[28] == 0, commentBytes[29] != 0 {
            comment = ID3v1Tag.trimmedString(commentRange.dropLast(2))
            track = commentBytes[29]
        } else {
            comment = ID3v1Tag.trimmedString(commentRange)
            track = nil
        }

        genre = data[start + 127]
    }

    var summaryParts: [String] {
        var parts: [String] = []
        if !title.isEmpty { parts.append("Titel: \(title)") }
        if !artist.isEmpty { parts.append("Interpret: \(artist)") }
        if !album.isEmpty { parts.append("Album: \(album)") }
        if !year.isEmpty { parts.append("Jahr: \(year)") }
        if !comment.isEmpty { parts.append("Kommentar: \(comment)") }
        if let track { parts.append("Track: \(track)") }
        return parts
    }

    private static func trimmedString<S: Sequence>(_ bytes: S) -> String where S.Element == UInt8 {
        let data = Data(bytes)
        return (String(data: data, encoding: .isoLatin1) ?? "")
            .trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines))
    }
}

private extension ID3Frame {
    var textValue: String? {
        guard payload.count >= 1 else { return nil }
        return decodeID3Text(payload.dropFirst())
    }

    var commentValue: String? {
        guard payload.count >= 4 else { return nil }

        let body = payload.dropFirst(4)
        let bytes = [UInt8](body)
        let textStart: Int
        if let zeroIndex = bytes.firstIndex(of: 0) {
            textStart = zeroIndex + 1
        } else {
            textStart = 0
        }

        guard textStart <= bytes.count else { return nil }
        return decodeID3Text(bytes[textStart...])
    }

    private func decodeID3Text<S: Sequence>(_ bytes: S) -> String? where S.Element == UInt8 {
        guard let encodingByte = payload.first else { return nil }
        let data = Data(bytes)

        switch encodingByte {
        case 0x00:
            return String(data: data, encoding: .isoLatin1)?.trimmedForID3
        case 0x01:
            return String(data: data, encoding: .utf16)?.trimmedForID3
        case 0x02:
            return String(data: data, encoding: .utf16BigEndian)?.trimmedForID3
        case 0x03:
            return String(data: data, encoding: .utf8)?.trimmedForID3
        default:
            return String(data: data, encoding: .utf8)?.trimmedForID3
        }
    }
}

private extension String {
    var trimmedForID3: String {
        trimmingCharacters(in: CharacterSet(charactersIn: "\0 ").union(.newlines))
    }
}

private func syncsafeInteger<S: Sequence>(_ bytes: S) -> Int where S.Element == UInt8 {
    bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7F) }
}

private func bigEndianInteger<S: Sequence>(_ bytes: S) -> Int where S.Element == UInt8 {
    bytes.reduce(0) { ($0 << 8) | Int($1) }
}

private func syncsafeBytes(_ value: Int) -> [UInt8] {
    [
        UInt8((value >> 21) & 0x7F),
        UInt8((value >> 14) & 0x7F),
        UInt8((value >> 7) & 0x7F),
        UInt8(value & 0x7F)
    ]
}

private func bigEndianBytes(_ value: Int) -> [UInt8] {
    [
        UInt8((value >> 24) & 0xFF),
        UInt8((value >> 16) & 0xFF),
        UInt8((value >> 8) & 0xFF),
        UInt8(value & 0xFF)
    ]
}

private extension Pipe {
    func readString() -> String {
        let data = fileHandleForReading.readDataToEndOfFile()
        return String(data: data, encoding: .utf8)?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
    }
}

private extension DateFormatter {
    static let reportDateTime: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        return formatter
    }()
}

private extension Array where Element == ExportMessage {
    var containsErrors: Bool {
        contains { $0.kind == .error }
    }

    var containsWarnings: Bool {
        contains { $0.kind == .warning }
    }
}

private extension Array {
    func chunked(into size: Int) -> [[Element]] {
        guard size > 0 else {
            return [self]
        }

        return stride(from: 0, to: count, by: size).map {
            Array(self[$0..<Swift.min($0 + size, count)])
        }
    }
}

private extension Array where Element == URL {
    func sortedByLocalizedName() -> [URL] {
        sorted {
            $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending
        }
    }
}
