//
// HeicBuilder.swift
//
// Native helper for Dynamic Wallpaper Creator.
//
// macOS Dynamic Desktop wallpapers are single .heic (HEIF) files that contain
// several top-level still images plus a small binary property list, embedded
// as an XMP tag, that tells System Settings which image to show for which
// sun position (solar mode) or which image is "light" and which is "dark"
// (appearance mode). That embedding can only be done with Apple's own
// ImageIO / CoreGraphics frameworks, so this helper is written in Swift and
// invoked by the Java application as an external process. The Java side
// never has to know about CGImageDestination or property lists - it just
// writes a small JSON "job" file and reads this tool's exit code/output.
//
// Usage:
//   heic-builder build <job.json>
//   heic-builder inspect <file.heic>     (debugging aid, prints embedded metadata)
//   heic-builder --version
//
// Job file schema (see docs/FILE_FORMAT.md for the full spec):
// {
//   "mode": "appearance" | "solar",
//   "output": "/absolute/path/out.heic",
//   "quality": 0.9,
//   "light": "/abs/light.png",              // appearance mode only
//   "dark": "/abs/dark.png",                // appearance mode only
//   "images": [                             // solar mode only, ordered
//     { "path": "/abs/01.png", "altitude": 45.0, "azimuth": 120.0,
//       "lightReference": true, "darkReference": false }
//   ]
// }

import Foundation
import ImageIO
import CoreGraphics

let APPLE_DESKTOP_NAMESPACE = "http://ns.apple.com/namespace/1.0/" as CFString
let APPLE_DESKTOP_PREFIX = "apple_desktop" as CFString

struct ToolError: Error, CustomStringConvertible {
    let message: String
    var description: String { message }
}

func fail(_ message: String) -> Never {
    FileHandle.standardError.write(("ERROR: " + message + "\n").data(using: .utf8)!)
    exit(1)
}

func log(_ message: String) {
    print(message)
    fflush(stdout)
}

// MARK: - JSON job parsing

struct SolarImageEntry {
    let path: String
    let altitude: Double
    let azimuth: Double
    let lightReference: Bool
    let darkReference: Bool
}

enum Job {
    case appearance(light: String, dark: String, output: String, quality: Double)
    case solar(images: [SolarImageEntry], output: String, quality: Double)
}

func loadJob(path: String) throws -> Job {
    let data = try Data(contentsOf: URL(fileURLWithPath: path))
    guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
        throw ToolError(message: "job file is not a JSON object")
    }
    guard let mode = json["mode"] as? String else {
        throw ToolError(message: "job file is missing 'mode'")
    }
    guard let output = json["output"] as? String else {
        throw ToolError(message: "job file is missing 'output'")
    }
    let quality = (json["quality"] as? Double) ?? 0.9

    switch mode {
    case "appearance":
        guard let light = json["light"] as? String, let dark = json["dark"] as? String else {
            throw ToolError(message: "appearance job requires 'light' and 'dark' paths")
        }
        return .appearance(light: light, dark: dark, output: output, quality: quality)
    case "solar":
        guard let rawImages = json["images"] as? [[String: Any]], !rawImages.isEmpty else {
            throw ToolError(message: "solar job requires a non-empty 'images' array")
        }
        let images: [SolarImageEntry] = try rawImages.map { entry in
            guard let path = entry["path"] as? String else {
                throw ToolError(message: "solar image entry is missing 'path'")
            }
            guard let altitude = entry["altitude"] as? Double else {
                throw ToolError(message: "solar image entry '\(path)' is missing numeric 'altitude'")
            }
            guard let azimuth = entry["azimuth"] as? Double else {
                throw ToolError(message: "solar image entry '\(path)' is missing numeric 'azimuth'")
            }
            let lightRef = (entry["lightReference"] as? Bool) ?? false
            let darkRef = (entry["darkReference"] as? Bool) ?? false
            return SolarImageEntry(path: path, altitude: altitude, azimuth: azimuth,
                                    lightReference: lightRef, darkReference: darkRef)
        }
        return .solar(images: images, output: output, quality: quality)
    default:
        throw ToolError(message: "unknown mode '\(mode)', expected 'appearance' or 'solar'")
    }
}

// MARK: - Property list -> base64

func base64Plist(_ object: Any) throws -> String {
    let data = try PropertyListSerialization.data(fromPropertyList: object, format: .binary, options: 0)
    return data.base64EncodedString()
}

// MARK: - Image loading

func loadCGImage(path: String) throws -> CGImage {
    let url = URL(fileURLWithPath: path)
    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
        throw ToolError(message: "could not open image: \(path)")
    }
    guard let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
        throw ToolError(message: "could not decode image: \(path)")
    }
    return image
}

// MARK: - Writing the HEIC container

func makeMetadata(namespace: CFString, prefix: CFString, name: CFString, base64Value: String) -> CGMutableImageMetadata {
    let metadata = CGImageMetadataCreateMutable()
    guard CGImageMetadataRegisterNamespaceForPrefix(metadata, namespace, prefix, nil) else {
        fail("failed to register apple_desktop XMP namespace")
    }
    let tag = CGImageMetadataTagCreate(namespace, prefix, name, .string, base64Value as CFTypeRef)!
    let path = (prefix as String) + ":" + (name as String)
    _ = CGImageMetadataSetTagWithPath(metadata, nil, path as CFString, tag)
    return metadata
}

func writeHeic(images: [CGImage], primaryMetadata: CGMutableImageMetadata?, quality: Double, output: String) throws {
    let outputURL = URL(fileURLWithPath: output)
    try? FileManager.default.removeItem(at: outputURL)

    guard let dest = CGImageDestinationCreateWithURL(outputURL as CFURL, "public.heic" as CFString, images.count, nil) else {
        throw ToolError(message: "could not create HEIC destination at \(output)")
    }

    let properties: [CFString: Any] = [
        kCGImageDestinationLossyCompressionQuality: quality
    ]

    for (index, image) in images.enumerated() {
        if index == 0, let metadata = primaryMetadata {
            CGImageDestinationAddImageAndMetadata(dest, image, metadata, properties as CFDictionary)
        } else {
            CGImageDestinationAddImage(dest, image, properties as CFDictionary)
        }
    }

    guard CGImageDestinationFinalize(dest) else {
        throw ToolError(message: "failed to finalize HEIC file at \(output)")
    }
}

// MARK: - Build commands

func buildAppearance(light: String, dark: String, output: String, quality: Double) throws {
    log("Loading light image: \(light)")
    let lightImage = try loadCGImage(path: light)
    log("Loading dark image: \(dark)")
    let darkImage = try loadCGImage(path: dark)

    let plist: [String: Any] = ["l": 0, "d": 1]
    let base64 = try base64Plist(plist)
    let metadata = makeMetadata(namespace: APPLE_DESKTOP_NAMESPACE, prefix: APPLE_DESKTOP_PREFIX,
                                 name: "apr" as CFString, base64Value: base64)

    log("Writing \(output)")
    try writeHeic(images: [lightImage, darkImage], primaryMetadata: metadata, quality: quality, output: output)
    log("OK \(output)")
}

func buildSolar(images: [SolarImageEntry], output: String, quality: Double) throws {
    var cgImages: [CGImage] = []
    for (i, entry) in images.enumerated() {
        log("Loading image \(i + 1)/\(images.count): \(entry.path)")
        cgImages.append(try loadCGImage(path: entry.path))
    }

    let siArray: [[String: Any]] = images.enumerated().map { (i, entry) in
        ["i": i, "a": entry.altitude, "z": entry.azimuth]
    }

    var lightIndex = images.firstIndex(where: { $0.lightReference })
    var darkIndex = images.firstIndex(where: { $0.darkReference })
    if lightIndex == nil {
        lightIndex = images.indices.max(by: { images[$0].altitude < images[$1].altitude })
    }
    if darkIndex == nil {
        darkIndex = images.indices.max(by: { images[$0].altitude > images[$1].altitude })
    }

    let plist: [String: Any] = [
        "si": siArray,
        "ap": ["l": lightIndex ?? 0, "d": darkIndex ?? 0]
    ]
    let base64 = try base64Plist(plist)
    let metadata = makeMetadata(namespace: APPLE_DESKTOP_NAMESPACE, prefix: APPLE_DESKTOP_PREFIX,
                                 name: "solar" as CFString, base64Value: base64)

    log("Writing \(output)")
    try writeHeic(images: cgImages, primaryMetadata: metadata, quality: quality, output: output)
    log("OK \(output)")
}

// MARK: - Inspect command (debugging aid)

func inspect(path: String) throws {
    let url = URL(fileURLWithPath: path)
    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
        throw ToolError(message: "could not open \(path)")
    }
    let count = CGImageSourceGetCount(source)
    print("images: \(count)")
    for i in 0..<count {
        guard let metadata = CGImageSourceCopyMetadataAtIndex(source, i, nil) else { continue }
        let tags = CGImageMetadataCopyTags(metadata) as? [CGImageMetadataTag] ?? []
        for tag in tags {
            guard let prefix = CGImageMetadataTagCopyPrefix(tag) as String?, prefix == "apple_desktop" else { continue }
            let name = CGImageMetadataTagCopyName(tag) as String? ?? "?"
            guard let value = CGImageMetadataTagCopyValue(tag) as? String,
                  let data = Data(base64Encoded: value) else { continue }
            var format = PropertyListSerialization.PropertyListFormat.binary
            if let plist = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format) {
                print("image \(i) apple_desktop:\(name) = \(plist)")
            }
        }
    }
}

// MARK: - Entry point

let arguments = CommandLine.arguments
guard arguments.count >= 2 else {
    fail("usage: heic-builder build <job.json> | inspect <file.heic> | --version")
}

switch arguments[1] {
case "--version":
    print("heic-builder 1.0")
case "build":
    guard arguments.count >= 3 else { fail("usage: heic-builder build <job.json>") }
    do {
        let job = try loadJob(path: arguments[2])
        switch job {
        case .appearance(let light, let dark, let output, let quality):
            try buildAppearance(light: light, dark: dark, output: output, quality: quality)
        case .solar(let images, let output, let quality):
            try buildSolar(images: images, output: output, quality: quality)
        }
    } catch {
        fail("\(error)")
    }
case "inspect":
    guard arguments.count >= 3 else { fail("usage: heic-builder inspect <file.heic>") }
    do {
        try inspect(path: arguments[2])
    } catch {
        fail("\(error)")
    }
default:
    fail("unknown command '\(arguments[1])'")
}
