import Foundation

final class LockedOutputBuffer: @unchecked Sendable {
    private let lock = NSLock()
    private var storage = ""

    func append(_ value: String) {
        lock.lock()
        storage += value
        lock.unlock()
    }

    func value() -> String {
        lock.lock()
        let result = storage
        lock.unlock()
        return result
    }
}

struct ProcessResult {
    let exitCode: Int32
    let output: String
}

actor FFmpegService {
    private var runningProcess: Process?

    func findExecutable(named name: String) -> URL? {
        let candidates = [
            "/opt/homebrew/bin/\(name)",
            "/usr/local/bin/\(name)",
            "/usr/bin/\(name)"
        ]

        for path in candidates where FileManager.default.isExecutableFile(atPath: path) {
            return URL(fileURLWithPath: path)
        }

        let environmentPath = ProcessInfo.processInfo.environment["PATH"] ?? ""
        for directory in environmentPath.split(separator: ":") {
            let path = String(directory) + "/\(name)"
            if FileManager.default.isExecutableFile(atPath: path) {
                return URL(fileURLWithPath: path)
            }
        }

        return nil
    }

    func validateTools(ffmpegOverride: URL? = nil, ffprobeOverride: URL? = nil) throws -> (ffmpeg: URL, ffprobe: URL) {
        let ffmpeg: URL?
        if let ffmpegOverride {
            guard FileManager.default.isExecutableFile(atPath: ffmpegOverride.path) else {
                throw VideoProcessingError.invalidToolPath("ffmpeg", ffmpegOverride.path)
            }
            ffmpeg = ffmpegOverride
        } else {
            ffmpeg = findExecutable(named: "ffmpeg")
        }

        let ffprobe: URL?
        if let ffprobeOverride {
            guard FileManager.default.isExecutableFile(atPath: ffprobeOverride.path) else {
                throw VideoProcessingError.invalidToolPath("ffprobe", ffprobeOverride.path)
            }
            ffprobe = ffprobeOverride
        } else {
            ffprobe = findExecutable(named: "ffprobe")
        }

        guard let ffmpeg else {
            throw VideoProcessingError.missingTool("ffmpeg")
        }
        guard let ffprobe else {
            throw VideoProcessingError.missingTool("ffprobe")
        }
        return (ffmpeg, ffprobe)
    }

    func cancel() {
        runningProcess?.terminate()
        runningProcess = nil
    }

    func run(
        executable: URL,
        arguments: [String],
        currentDirectory: URL? = nil,
        onOutput: @escaping @Sendable (String) -> Void = { _ in }
    ) async throws -> ProcessResult {
        try Task.checkCancellation()

        return try await withTaskCancellationHandler {
            try await withCheckedThrowingContinuation { continuation in
                let process = Process()
                process.executableURL = executable
                process.arguments = arguments
                process.currentDirectoryURL = currentDirectory

                let pipe = Pipe()
                let nullInput = FileHandle(forReadingAtPath: "/dev/null")
                process.standardOutput = pipe
                process.standardError = pipe
                process.standardInput = nullInput

                let outputBuffer = LockedOutputBuffer()

                pipe.fileHandleForReading.readabilityHandler = { handle in
                    let data = handle.availableData
                    guard !data.isEmpty, let chunk = String(data: data, encoding: .utf8) else {
                        return
                    }
                    outputBuffer.append(chunk)
                    onOutput(chunk)
                }

                process.terminationHandler = { finishedProcess in
                    pipe.fileHandleForReading.readabilityHandler = nil
                    nullInput?.closeFile()
                    let output = outputBuffer.value()
                    Task {
                        await self.clearRunningProcess(process)
                    }
                    continuation.resume(returning: ProcessResult(exitCode: finishedProcess.terminationStatus, output: output))
                }

                do {
                    runningProcess = process
                    try process.run()
                } catch {
                    pipe.fileHandleForReading.readabilityHandler = nil
                    nullInput?.closeFile()
                    runningProcess = nil
                    continuation.resume(throwing: error)
                }
            }
        } onCancel: {
            Task {
                await self.cancel()
            }
        }
    }

    func availableFilters(ffmpeg: URL) async throws -> FFmpegFilterAvailability {
        let result = try await run(
            executable: ffmpeg,
            arguments: ["-hide_banner", "-filters"]
        )

        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Filterprüfung", result.output)
        }

        return FFmpegFilterAvailability(
            hasVidstabDetect: result.output.contains("vidstabdetect"),
            hasVidstabTransform: result.output.contains("vidstabtransform"),
            hasDeshake: result.output.contains("deshake"),
            hasDrawtext: result.output.contains("drawtext")
        )
    }

    private func clearRunningProcess(_ process: Process) {
        if runningProcess === process {
            runningProcess = nil
        }
    }
}

enum VideoProcessingError: LocalizedError {
    case missingTool(String)
    case invalidToolPath(String, String)
    case invalidSettings(String)
    case noVideosFound
    case ffprobeFailed(URL, String)
    case ffmpegFailed(String, String)
    case cancelled

    var errorDescription: String? {
        switch self {
        case .missingTool(let tool):
            return "\(tool) wurde nicht gefunden. Bitte installiere ffmpeg lokal, zum Beispiel mit Homebrew: brew install ffmpeg"
        case .invalidToolPath(let tool, let path):
            return "Der angegebene \(tool)-Pfad ist nicht ausführbar:\n\(path)\n\nBitte wähle die ausführbare Datei selbst aus, nicht nur den Ordner."
        case .invalidSettings(let message):
            return message
        case .noVideosFound:
            return "Im gewählten Ordner wurden keine unterstützten Videodateien gefunden."
        case .ffprobeFailed(let url, let output):
            let details = output.trimmingCharacters(in: .whitespacesAndNewlines)
            if details.isEmpty {
                return "Die Dauer von \(url.lastPathComponent) konnte nicht gelesen werden."
            }
            return "Die Dauer von \(url.lastPathComponent) konnte nicht gelesen werden.\n\(shortErrorDetails(details))"
        case .ffmpegFailed(let step, let output):
            let details = output.trimmingCharacters(in: .whitespacesAndNewlines)
            if details.isEmpty {
                return "ffmpeg ist bei Schritt \"\(step)\" fehlgeschlagen. Details stehen im Log."
            }
            return "ffmpeg ist bei Schritt \"\(step)\" fehlgeschlagen.\n\(shortErrorDetails(details))\n\nDetails stehen im Log."
        case .cancelled:
            return "Rendern wurde abgebrochen."
        }
    }
}

private func shortErrorDetails(_ output: String) -> String {
    let lines = output
        .replacingOccurrences(of: "\r", with: "\n")
        .split(separator: "\n")
        .map(String.init)
        .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }

    let importantLines = lines.filter { line in
        let lower = line.lowercased()
        return lower.contains("error")
            || lower.contains("failed")
            || lower.contains("invalid")
            || lower.contains("not found")
            || lower.contains("no such")
            || lower.contains("unable")
    }

    let selected: [String]
    if importantLines.isEmpty {
        selected = Array(lines.suffix(8))
    } else {
        selected = Array((importantLines.suffix(4) + lines.suffix(4)).suffix(8))
    }
    let text = selected.joined(separator: "\n")

    if text.count <= 700 {
        return text
    }

    let endIndex = text.index(text.startIndex, offsetBy: 700)
    return String(text[..<endIndex]) + "..."
}
