import AppKit
import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers

enum TextureProcessorError: LocalizedError {
    case missingBaseColor
    case missingDiffuseTexture
    case missingNormalSource
    case cannotReadImage(URL)
    case cannotCreateBitmap
    case cannotCreatePNG(URL)
    case cannotWritePNG(URL)
    case cannotWriteText(URL)

    var errorDescription: String? {
        switch self {
        case .missingBaseColor:
            "Base Color texture is required."
        case .missingDiffuseTexture:
            "Diffuse/Base texture is required."
        case .missingNormalSource:
            "Source texture is required."
        case .cannotReadImage(let url):
            "Could not read image: \(url.lastPathComponent)"
        case .cannotCreateBitmap:
            "Could not create bitmap buffer."
        case .cannotCreatePNG(let url):
            "Could not create PNG image: \(url.lastPathComponent)"
        case .cannotWritePNG(let url):
            "Could not write PNG: \(url.lastPathComponent)"
        case .cannotWriteText(let url):
            "Could not write file: \(url.lastPathComponent)"
        }
    }
}

struct PixelImage {
    let width: Int
    let height: Int
    var pixels: [UInt8]
}

final class TextureProcessor {
    private let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
    private let bitmapInfo = CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue

    func loadAsset(from url: URL) throws -> TextureAsset {
        let image = try loadCGImage(from: url)
        let preview = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
        return TextureAsset(
            url: url,
            fileName: url.lastPathComponent,
            pixelWidth: image.width,
            pixelHeight: image.height,
            preview: preview
        )
    }

    func exportMaterialSet(
        textures: [TextureSlotKind: TextureAsset],
        settings: ConversionSettings,
        parentDirectory: URL
    ) throws -> ExportResult {
        guard let base = textures[.baseColor] else {
            throw TextureProcessorError.missingBaseColor
        }

        let outputSize = try resolvedOutputSize(base: base, settings: settings)
        let folderURL = parentDirectory.appendingPathComponent(settings.safeMaterialName, isDirectory: true)
        try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)

        let names = OutputNames(materialName: settings.safeMaterialName)
        let baseImage = try renderImage(from: base.url, width: outputSize.width, height: outputSize.height)
        try writePNG(baseImage, to: folderURL.appendingPathComponent(names.baseColor))

        let ormImage = try makeORM(textures: textures, settings: settings, width: outputSize.width, height: outputSize.height)
        try writePNG(ormImage, to: folderURL.appendingPathComponent(names.orm))

        var writtenFiles = [names.baseColor, names.orm]
        var hasNormal = false

        if let normal = textures[.normal] {
            var normalImage = try renderImage(from: normal.url, width: outputSize.width, height: outputSize.height)
            normalizeAlpha(&normalImage)
            if settings.flipNormalGreen {
                flipGreenChannel(&normalImage)
            }
            try writePNG(normalImage, to: folderURL.appendingPathComponent(names.normal))
            writtenFiles.append(names.normal)
            hasNormal = true
        }

        if settings.exportGLTF {
            let data = try makeGLTFData(names: names, settings: settings, hasNormal: hasNormal)
            let gltfURL = folderURL.appendingPathComponent(names.gltf)
            do {
                try data.write(to: gltfURL, options: .atomic)
                writtenFiles.append(names.gltf)
            } catch {
                throw TextureProcessorError.cannotWriteText(gltfURL)
            }
        }

        if settings.exportNotes {
            let notesURL = folderURL.appendingPathComponent(names.notes)
            do {
                try makeNotes(names: names, settings: settings, textures: textures, hasNormal: hasNormal)
                    .write(to: notesURL, atomically: true, encoding: .utf8)
                writtenFiles.append(names.notes)
            } catch {
                throw TextureProcessorError.cannotWriteText(notesURL)
            }
        }

        return ExportResult(folderURL: folderURL, files: writtenFiles)
    }

    func buildPreviews(
        textures: [TextureSlotKind: TextureAsset],
        settings: ConversionSettings
    ) throws -> [OutputPreviewKind: NSImage] {
        guard let base = textures[.baseColor] ?? textures.values.first else {
            return [:]
        }

        let outputSize = previewSize(from: try resolvedOutputSize(base: base, settings: settings))
        var previews: [OutputPreviewKind: NSImage] = [:]

        if let baseTexture = textures[.baseColor] {
            let baseImage = try renderImage(from: baseTexture.url, width: outputSize.width, height: outputSize.height)
            previews[.baseColor] = try makeNSImage(from: baseImage)
        }

        let ormImage = try makeORM(textures: textures, settings: settings, width: outputSize.width, height: outputSize.height)
        previews[.orm] = try makeNSImage(from: ormImage)

        if let normal = textures[.normal] {
            var normalImage = try renderImage(from: normal.url, width: outputSize.width, height: outputSize.height)
            normalizeAlpha(&normalImage)
            if settings.flipNormalGreen {
                flipGreenChannel(&normalImage)
            }
            previews[.normal] = try makeNSImage(from: normalImage)
        }

        return previews
    }

    func exportGeneratedNormalSet(
        source: TextureAsset?,
        settings: NormalGeneratorSettings,
        parentDirectory: URL
    ) throws -> ExportResult {
        guard let source else {
            throw TextureProcessorError.missingNormalSource
        }

        let outputSize = resolvedOutputSize(baseWidth: source.pixelWidth, baseHeight: source.pixelHeight, resolution: settings.outputResolution)
        let folderURL = parentDirectory.appendingPathComponent(settings.safeMaterialName, isDirectory: true)
        try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true)

        let names = NormalOutputNames(materialName: settings.safeMaterialName)
        let outputs = try makeGeneratedNormalOutputs(
            sourceURL: source.url,
            settings: settings,
            width: outputSize.width,
            height: outputSize.height
        )

        try writePNG(outputs.source, to: folderURL.appendingPathComponent(names.baseColor))
        try writePNG(outputs.normal, to: folderURL.appendingPathComponent(names.normal))
        var writtenFiles = [names.baseColor, names.normal]

        if settings.exportHeight {
            try writePNG(outputs.height, to: folderURL.appendingPathComponent(names.height))
            writtenFiles.append(names.height)
        }

        if settings.exportRoughness {
            try writePNG(outputs.roughness, to: folderURL.appendingPathComponent(names.roughness))
            writtenFiles.append(names.roughness)
        }

        if settings.exportGloss {
            try writePNG(outputs.gloss, to: folderURL.appendingPathComponent(names.gloss))
            writtenFiles.append(names.gloss)
        }

        if settings.exportMetallic {
            try writePNG(outputs.metallic, to: folderURL.appendingPathComponent(names.metallic))
            writtenFiles.append(names.metallic)
        }

        if settings.exportAO {
            try writePNG(outputs.occlusion, to: folderURL.appendingPathComponent(names.occlusion))
            writtenFiles.append(names.occlusion)
        }

        if settings.exportORM || settings.exportGLTF {
            try writePNG(outputs.orm, to: folderURL.appendingPathComponent(names.orm))
            writtenFiles.append(names.orm)
        }

        if settings.exportGLTF {
            let data = try makeGeneratedGLTFData(names: names, settings: settings)
            let gltfURL = folderURL.appendingPathComponent(names.gltf)
            do {
                try data.write(to: gltfURL, options: .atomic)
                writtenFiles.append(names.gltf)
            } catch {
                throw TextureProcessorError.cannotWriteText(gltfURL)
            }
        }

        if settings.exportNotes {
            let notesURL = folderURL.appendingPathComponent(names.notes)
            do {
                try makeNormalNotes(names: names, settings: settings, source: source)
                    .write(to: notesURL, atomically: true, encoding: .utf8)
                writtenFiles.append(names.notes)
            } catch {
                throw TextureProcessorError.cannotWriteText(notesURL)
            }
        }

        return ExportResult(folderURL: folderURL, files: writtenFiles)
    }

    func buildNormalPreviews(
        source: TextureAsset?,
        settings: NormalGeneratorSettings
    ) throws -> [NormalPreviewKind: NSImage] {
        guard let source else {
            return [:]
        }

        let outputSize = previewSize(from: resolvedOutputSize(baseWidth: source.pixelWidth, baseHeight: source.pixelHeight, resolution: settings.outputResolution))
        let outputs = try makeGeneratedNormalOutputs(
            sourceURL: source.url,
            settings: settings,
            width: outputSize.width,
            height: outputSize.height
        )

        return [
            .source: try makeNSImage(from: outputs.source),
            .height: try makeNSImage(from: outputs.height),
            .normal: try makeNSImage(from: outputs.normal),
            .roughness: try makeNSImage(from: outputs.roughness),
            .gloss: try makeNSImage(from: outputs.gloss),
            .metallic: try makeNSImage(from: outputs.metallic),
            .occlusion: try makeNSImage(from: outputs.occlusion),
            .orm: try makeNSImage(from: outputs.orm)
        ]
    }

    private func loadCGImage(from url: URL) throws -> CGImage {
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
              let image = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
            throw TextureProcessorError.cannotReadImage(url)
        }
        return image
    }

    private func renderImage(from url: URL, width: Int, height: Int) throws -> PixelImage {
        let image = try loadCGImage(from: url)
        var pixels = [UInt8](repeating: 0, count: width * height * 4)

        let didRender = pixels.withUnsafeMutableBytes { buffer -> Bool in
            guard let context = CGContext(
                data: buffer.baseAddress,
                width: width,
                height: height,
                bitsPerComponent: 8,
                bytesPerRow: width * 4,
                space: colorSpace,
                bitmapInfo: bitmapInfo
            ) else {
                return false
            }

            context.interpolationQuality = .high
            context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height))
            return true
        }

        guard didRender else {
            throw TextureProcessorError.cannotCreateBitmap
        }

        return PixelImage(width: width, height: height, pixels: pixels)
    }

    private func makeORM(
        textures: [TextureSlotKind: TextureAsset],
        settings: ConversionSettings,
        width: Int,
        height: Int
    ) throws -> PixelImage {
        let occlusion = try textures[.occlusion].map { try renderImage(from: $0.url, width: width, height: height) }
        let roughness = try textures[.roughness].map { try renderImage(from: $0.url, width: width, height: height) }
        let metallic = try textures[.metallic].map { try renderImage(from: $0.url, width: width, height: height) }

        let defaultOcclusion = byte(fromUnit: settings.defaultOcclusion)
        let defaultRoughness = byte(fromUnit: settings.defaultRoughness)
        let defaultMetallic = byte(fromUnit: settings.defaultMetallic)

        var pixels = [UInt8](repeating: 255, count: width * height * 4)

        for index in 0..<(width * height) {
            let offset = index * 4
            pixels[offset] = grayscale(from: occlusion, offset: offset, fallback: defaultOcclusion, inverted: false)
            pixels[offset + 1] = grayscale(from: roughness, offset: offset, fallback: defaultRoughness, inverted: settings.roughnessInputIsGloss)
            pixels[offset + 2] = grayscale(from: metallic, offset: offset, fallback: defaultMetallic, inverted: false)
            pixels[offset + 3] = 255
        }

        return PixelImage(width: width, height: height, pixels: pixels)
    }

    private func makeGeneratedNormalOutputs(
        sourceURL: URL,
        settings: NormalGeneratorSettings,
        width: Int,
        height: Int
    ) throws -> NormalOutputs {
        let source = try renderImage(from: sourceURL, width: width, height: height)
        let heightValues = smoothedHeightValues(from: source, settings: settings)
        let heightImage = heightImage(from: heightValues, width: width, height: height)
        let normalImage = normalImage(from: heightValues, width: width, height: height, settings: settings)
        let generated = generatedSurfaceMaps(source: source, heightValues: heightValues, settings: settings)

        return NormalOutputs(
            source: source,
            height: heightImage,
            normal: normalImage,
            roughness: generated.roughness,
            gloss: generated.gloss,
            metallic: generated.metallic,
            occlusion: generated.occlusion,
            orm: generated.orm
        )
    }

    private func smoothedHeightValues(from image: PixelImage, settings: NormalGeneratorSettings) -> [Double] {
        var heights = [Double](repeating: 0, count: image.width * image.height)

        for pixelIndex in 0..<(image.width * image.height) {
            let offset = pixelIndex * 4
            let value = Double(grayscale(from: image, offset: offset, fallback: 0, inverted: settings.invertHeight)) / 255.0
            heights[pixelIndex] = value
        }

        guard settings.smoothing > 0 else {
            return heights
        }

        var working = heights
        for _ in 0..<settings.smoothing {
            working = boxBlur(values: working, width: image.width, height: image.height)
        }

        return working
    }

    private func boxBlur(values: [Double], width: Int, height: Int) -> [Double] {
        var blurred = values

        for y in 0..<height {
            for x in 0..<width {
                var total = 0.0
                var count = 0.0

                for sampleY in max(0, y - 1)...min(height - 1, y + 1) {
                    for sampleX in max(0, x - 1)...min(width - 1, x + 1) {
                        total += values[(sampleY * width) + sampleX]
                        count += 1
                    }
                }

                blurred[(y * width) + x] = total / count
            }
        }

        return blurred
    }

    private func heightImage(from values: [Double], width: Int, height: Int) -> PixelImage {
        var pixels = [UInt8](repeating: 255, count: width * height * 4)

        for pixelIndex in 0..<(width * height) {
            let byteValue = byte(fromUnit: values[pixelIndex])
            let offset = pixelIndex * 4
            pixels[offset] = byteValue
            pixels[offset + 1] = byteValue
            pixels[offset + 2] = byteValue
            pixels[offset + 3] = 255
        }

        return PixelImage(width: width, height: height, pixels: pixels)
    }

    private func normalImage(
        from values: [Double],
        width: Int,
        height: Int,
        settings: NormalGeneratorSettings
    ) -> PixelImage {
        var pixels = [UInt8](repeating: 255, count: width * height * 4)
        let strength = max(0.05, settings.strength)

        for y in 0..<height {
            for x in 0..<width {
                let left = sampleHeight(values, width: width, height: height, x: x - 1, y: y)
                let right = sampleHeight(values, width: width, height: height, x: x + 1, y: y)
                let up = sampleHeight(values, width: width, height: height, x: x, y: y - 1)
                let down = sampleHeight(values, width: width, height: height, x: x, y: y + 1)

                let dx = (right - left) * strength
                var dy = (down - up) * strength
                if settings.flipGreen {
                    dy = -dy
                }

                let normal = normalized(x: -dx, y: -dy, z: 1.0)
                let offset = ((y * width) + x) * 4
                pixels[offset] = byte(fromSignedUnit: normal.x)
                pixels[offset + 1] = byte(fromSignedUnit: normal.y)
                pixels[offset + 2] = byte(fromSignedUnit: normal.z)
                pixels[offset + 3] = 255
            }
        }

        return PixelImage(width: width, height: height, pixels: pixels)
    }

    private func generatedSurfaceMaps(
        source: PixelImage,
        heightValues: [Double],
        settings: NormalGeneratorSettings
    ) -> GeneratedSurfaceMaps {
        let width = source.width
        let height = source.height
        let pixelCount = width * height
        let blurredHeights = boxBlur(values: heightValues, width: width, height: height)
        let roughnessMin = min(settings.roughnessMin, settings.roughnessMax)
        let roughnessMax = max(settings.roughnessMin, settings.roughnessMax)
        let roughnessRange = max(0.02, roughnessMax - roughnessMin)

        var roughnessPixels = [UInt8](repeating: 255, count: pixelCount * 4)
        var glossPixels = [UInt8](repeating: 255, count: pixelCount * 4)
        var metallicPixels = [UInt8](repeating: 255, count: pixelCount * 4)
        var occlusionPixels = [UInt8](repeating: 255, count: pixelCount * 4)
        var ormPixels = [UInt8](repeating: 255, count: pixelCount * 4)

        for y in 0..<height {
            for x in 0..<width {
                let pixelIndex = (y * width) + x
                let offset = pixelIndex * 4
                let red = Double(source.pixels[offset]) / 255.0
                let green = Double(source.pixels[offset + 1]) / 255.0
                let blue = Double(source.pixels[offset + 2]) / 255.0
                let maxColor = max(red, green, blue)
                let minColor = min(red, green, blue)
                let saturation = maxColor <= 0 ? 0 : (maxColor - minColor) / maxColor
                let luminance = (red * 0.2126) + (green * 0.7152) + (blue * 0.0722)
                let localContrast = localHeightContrast(heightValues, width: width, height: height, x: x, y: y)

                let highlight = smoothStep(edge0: 0.42, edge1: 0.92, value: luminance)
                let darkCrease = clamp((blurredHeights[pixelIndex] - heightValues[pixelIndex]) * 3.0, min: 0, max: 1)
                let glossySignal = clamp((highlight * 0.75) + (localContrast * 1.6), min: 0, max: 1)
                let roughnessValue = clamp(roughnessMax - (glossySignal * roughnessRange), min: roughnessMin, max: roughnessMax)
                let glossValue = 1.0 - roughnessValue

                let neutralMetal = 1.0 - saturation
                let metalBrightness = smoothStep(edge0: 0.18, edge1: 0.78, value: luminance)
                let metallicValue = clamp(neutralMetal * metalBrightness * settings.metallicStrength, min: 0, max: 1)
                let occlusionValue = clamp(1.0 - (darkCrease * settings.aoStrength), min: 0, max: 1)

                let roughnessByte = byte(fromUnit: roughnessValue)
                let glossByte = byte(fromUnit: glossValue)
                let metallicByte = byte(fromUnit: metallicValue)
                let occlusionByte = byte(fromUnit: occlusionValue)

                writeGray(roughnessByte, into: &roughnessPixels, offset: offset)
                writeGray(glossByte, into: &glossPixels, offset: offset)
                writeGray(metallicByte, into: &metallicPixels, offset: offset)
                writeGray(occlusionByte, into: &occlusionPixels, offset: offset)

                ormPixels[offset] = occlusionByte
                ormPixels[offset + 1] = roughnessByte
                ormPixels[offset + 2] = metallicByte
                ormPixels[offset + 3] = 255
            }
        }

        return GeneratedSurfaceMaps(
            roughness: PixelImage(width: width, height: height, pixels: roughnessPixels),
            gloss: PixelImage(width: width, height: height, pixels: glossPixels),
            metallic: PixelImage(width: width, height: height, pixels: metallicPixels),
            occlusion: PixelImage(width: width, height: height, pixels: occlusionPixels),
            orm: PixelImage(width: width, height: height, pixels: ormPixels)
        )
    }

    private func writeGray(_ value: UInt8, into pixels: inout [UInt8], offset: Int) {
        pixels[offset] = value
        pixels[offset + 1] = value
        pixels[offset + 2] = value
        pixels[offset + 3] = 255
    }

    private func localHeightContrast(_ values: [Double], width: Int, height: Int, x: Int, y: Int) -> Double {
        let center = sampleHeight(values, width: width, height: height, x: x, y: y)
        var maxDifference = 0.0

        for sampleY in max(0, y - 1)...min(height - 1, y + 1) {
            for sampleX in max(0, x - 1)...min(width - 1, x + 1) {
                let sample = values[(sampleY * width) + sampleX]
                maxDifference = max(maxDifference, abs(center - sample))
            }
        }

        return clamp(maxDifference * 6.0, min: 0, max: 1)
    }

    private func sampleHeight(_ values: [Double], width: Int, height: Int, x: Int, y: Int) -> Double {
        let clampedX = max(0, min(width - 1, x))
        let clampedY = max(0, min(height - 1, y))
        return values[(clampedY * width) + clampedX]
    }

    private func normalized(x: Double, y: Double, z: Double) -> (x: Double, y: Double, z: Double) {
        let length = max(0.0001, sqrt((x * x) + (y * y) + (z * z)))
        return (x / length, y / length, z / length)
    }

    private func writePNG(_ image: PixelImage, to url: URL) throws {
        let cgImage = try makeCGImage(from: image, urlForError: url)
        guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
            throw TextureProcessorError.cannotCreatePNG(url)
        }

        CGImageDestinationAddImage(destination, cgImage, nil)
        guard CGImageDestinationFinalize(destination) else {
            throw TextureProcessorError.cannotWritePNG(url)
        }
    }

    private func makeCGImage(from image: PixelImage, urlForError: URL) throws -> CGImage {
        let nsData = NSData(bytes: image.pixels, length: image.pixels.count)
        guard let provider = CGDataProvider(data: nsData),
              let cgImage = CGImage(
                width: image.width,
                height: image.height,
                bitsPerComponent: 8,
                bitsPerPixel: 32,
                bytesPerRow: image.width * 4,
                space: colorSpace,
                bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo),
                provider: provider,
                decode: nil,
                shouldInterpolate: false,
                intent: .defaultIntent
              ) else {
            throw TextureProcessorError.cannotCreatePNG(urlForError)
        }

        return cgImage
    }

    private func makeNSImage(from image: PixelImage) throws -> NSImage {
        let previewURL = URL(fileURLWithPath: "Preview.png")
        let cgImage = try makeCGImage(from: image, urlForError: previewURL)
        return NSImage(cgImage: cgImage, size: NSSize(width: image.width, height: image.height))
    }

    private func resolvedOutputSize(base: TextureAsset, settings: ConversionSettings) throws -> (width: Int, height: Int) {
        if let fixed = settings.outputResolution.fixedPixels {
            return (fixed, fixed)
        }
        return (base.pixelWidth, base.pixelHeight)
    }

    private func resolvedOutputSize(baseWidth: Int, baseHeight: Int, resolution: OutputResolution) -> (width: Int, height: Int) {
        if let fixed = resolution.fixedPixels {
            return (fixed, fixed)
        }
        return (baseWidth, baseHeight)
    }

    private func previewSize(from size: (width: Int, height: Int)) -> (width: Int, height: Int) {
        let maxSide = max(size.width, size.height)
        guard maxSide > 512 else {
            return size
        }
        let scale = 512.0 / Double(maxSide)
        return (
            max(1, Int(Double(size.width) * scale)),
            max(1, Int(Double(size.height) * scale))
        )
    }

    private func grayscale(from image: PixelImage?, offset: Int, fallback: UInt8, inverted: Bool) -> UInt8 {
        guard let image else {
            return fallback
        }

        let red = Double(image.pixels[offset])
        let green = Double(image.pixels[offset + 1])
        let blue = Double(image.pixels[offset + 2])
        let luminance = UInt8(max(0, min(255, Int((red * 0.2126) + (green * 0.7152) + (blue * 0.0722)))))
        return inverted ? 255 - luminance : luminance
    }

    private func byte(fromUnit value: Double) -> UInt8 {
        UInt8(max(0, min(255, Int((value * 255.0).rounded()))))
    }

    private func byte(fromSignedUnit value: Double) -> UInt8 {
        byte(fromUnit: (value * 0.5) + 0.5)
    }

    private func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double {
        Swift.max(minimum, Swift.min(maximum, value))
    }

    private func smoothStep(edge0: Double, edge1: Double, value: Double) -> Double {
        guard edge0 != edge1 else {
            return value < edge0 ? 0 : 1
        }

        let t = clamp((value - edge0) / (edge1 - edge0), min: 0, max: 1)
        return t * t * (3 - (2 * t))
    }

    private func normalizeAlpha(_ image: inout PixelImage) {
        for index in stride(from: 3, to: image.pixels.count, by: 4) {
            image.pixels[index] = 255
        }
    }

    private func flipGreenChannel(_ image: inout PixelImage) {
        for index in stride(from: 1, to: image.pixels.count, by: 4) {
            image.pixels[index] = 255 - image.pixels[index]
        }
    }

    private func makeGLTFData(
        names: OutputNames,
        settings: ConversionSettings,
        hasNormal: Bool
    ) throws -> Data {
        var images: [[String: Any]] = [
            ["uri": names.baseColor],
            ["uri": names.orm]
        ]
        var textures: [[String: Any]] = [
            ["source": 0],
            ["source": 1]
        ]

        let nextIndex = 2
        var normalTextureIndex: Int?

        if hasNormal {
            images.append(["uri": names.normal])
            textures.append(["source": nextIndex])
            normalTextureIndex = textures.count - 1
        }

        var material: [String: Any] = [
            "name": settings.safeMaterialName,
            "doubleSided": true,
            "pbrMetallicRoughness": [
                "baseColorTexture": ["index": 0],
                "metallicRoughnessTexture": ["index": 1],
                "metallicFactor": 1.0,
                "roughnessFactor": 1.0
            ],
            "occlusionTexture": ["index": 1]
        ]

        if let normalTextureIndex {
            material["normalTexture"] = ["index": normalTextureIndex]
        }

        let document: [String: Any] = [
            "asset": [
                "version": "2.0",
                "generator": "BREDA TECH PBR Studio"
            ],
            "images": images,
            "textures": textures,
            "materials": [material]
        ]

        return try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys])
    }

    private func makeGeneratedGLTFData(
        names: NormalOutputNames,
        settings: NormalGeneratorSettings
    ) throws -> Data {
        let material: [String: Any] = [
            "name": settings.safeMaterialName,
            "doubleSided": true,
            "pbrMetallicRoughness": [
                "baseColorTexture": ["index": 0],
                "metallicRoughnessTexture": ["index": 1],
                "metallicFactor": 1.0,
                "roughnessFactor": 1.0
            ],
            "occlusionTexture": ["index": 1],
            "normalTexture": ["index": 2]
        ]

        let document: [String: Any] = [
            "asset": [
                "version": "2.0",
                "generator": "BREDA TECH PBR Studio"
            ],
            "images": [
                ["uri": names.baseColor],
                ["uri": names.orm],
                ["uri": names.normal]
            ],
            "textures": [
                ["source": 0],
                ["source": 1],
                ["source": 2]
            ],
            "materials": [material]
        ]

        return try JSONSerialization.data(withJSONObject: document, options: [.prettyPrinted, .sortedKeys])
    }

    private func makeNotes(
        names: OutputNames,
        settings: ConversionSettings,
        textures: [TextureSlotKind: TextureAsset],
        hasNormal: Bool
    ) -> String {
        let sourceLines = TextureSlotKind.allCases.map { kind -> String in
            if let asset = textures[kind] {
                return "\(kind.title): \(asset.fileName) (\(asset.sizeLabel))"
            }
            return "\(kind.title): not used"
        }.joined(separator: "\n")

        return """
        BREDA TECH PBR Studio - Second Life PBR Material Set

        Material name:
        \(settings.safeMaterialName)

        Output files:
        Base Color: \(names.baseColor)
        Metallic/Roughness: \(names.orm)
        \(hasNormal ? "Normal: \(names.normal)" : "Normal: not exported")
        \(settings.exportGLTF ? "glTF material: \(names.gltf)" : "glTF material: not exported")

        Second Life / glTF channel packing:
        ORM red: Ambient Occlusion. White means no occlusion.
        ORM green: Roughness. Black is glossy, white is rough.
        ORM blue: Metallic. Black is non-metal, white is metal.

        Settings:
        Output size: \(settings.outputResolution.title)
        Roughness input treated as gloss/smoothness: \(settings.roughnessInputIsGloss ? "yes" : "no")
        Normal green channel flipped: \(settings.flipNormalGreen ? "yes" : "no")
        Default AO: \(Formatters.percentLabel(settings.defaultOcclusion))
        Default roughness: \(Formatters.percentLabel(settings.defaultRoughness))
        Default metallic: \(Formatters.percentLabel(settings.defaultMetallic))

        Source files:
        \(sourceLines)

        Suggested SL workflow:
        Upload the .gltf through Build > Upload > Material, or upload the PNGs as textures and create a new PBR Metallic-Roughness material manually.
        In the manual material window, assign Base Color to the Base Color slot, assign ORM to Metallic/Roughness, and assign Normal if available.

        Practical notes:
        Old diffuse textures often include baked shadows or highlights. They can work as a starting point, but a cleaner base color gives more believable PBR.
        Most Second Life objects are non-metallic unless the surface is real bare metal.
        """
    }

    private func makeNormalNotes(
        names: NormalOutputNames,
        settings: NormalGeneratorSettings,
        source: TextureAsset
    ) -> String {
        return """
        BREDA TECH PBR Studio - Generated PBR Map Set

        Material name:
        \(settings.safeMaterialName)

        Output files:
        Base Color: \(names.baseColor)
        Normal map: \(names.normal)
        \(settings.exportHeight ? "Height helper: \(names.height)" : "Height helper: not exported")
        \(settings.exportRoughness ? "Roughness: \(names.roughness)" : "Roughness: not exported")
        \(settings.exportGloss ? "Gloss: \(names.gloss)" : "Gloss: not exported")
        \(settings.exportMetallic ? "Metallic: \(names.metallic)" : "Metallic: not exported")
        \(settings.exportAO ? "Ambient Occlusion: \(names.occlusion)" : "Ambient Occlusion: not exported")
        \((settings.exportORM || settings.exportGLTF) ? "ORM packed map: \(names.orm)" : "ORM packed map: not exported")
        \(settings.exportGLTF ? "glTF material: \(names.gltf)" : "glTF material: not exported")

        Source file:
        \(source.fileName) (\(source.sizeLabel))

        Settings:
        Output size: \(settings.outputResolution.title)
        Strength: \(String(format: "%.2f", settings.strength))
        Smoothing passes: \(settings.smoothing)
        Height inverted: \(settings.invertHeight ? "yes" : "no")
        Green channel flipped: \(settings.flipGreen ? "yes" : "no")
        Roughness min: \(Formatters.percentLabel(settings.roughnessMin))
        Roughness max: \(Formatters.percentLabel(settings.roughnessMax))
        Metallic strength: \(Formatters.percentLabel(settings.metallicStrength))
        AO strength: \(Formatters.percentLabel(settings.aoStrength))

        Suggested Second Life workflow:
        Upload \(names.gltf) through Build > Upload > Material when .gltf export is enabled.
        Use \(names.normal) in the Normal slot.
        Use \(names.orm) in the Metallic/Roughness slot if you exported ORM. It packs red = AO, green = roughness, blue = metallic.
        If you prefer manual slots, use roughness, metallic and AO separately where your workflow supports them.

        Practical notes:
        These maps are generated heuristically from one texture. They are useful for fast Second Life material setup, but they cannot know the true material physics.
        Metallic is intentionally conservative. Most surfaces should stay low metallic unless they are bare metal.
        Gloss is the inverse of roughness. For PBR/glTF/SL Metallic-Roughness, roughness is usually the map you want.
        A generated normal map estimates height from brightness, which is useful for fabric, stone, panels, seams and surface grain, but less reliable for painted shadows or highlights.
        If bumps look pushed inward, toggle Invert Height. If vertical lighting looks reversed, toggle Flip Green.
        """
    }
}

private struct OutputNames {
    let materialName: String

    var baseColor: String { "\(materialName)_BaseColor.png" }
    var orm: String { "\(materialName)_ORM.png" }
    var normal: String { "\(materialName)_Normal.png" }
    var gltf: String { "\(materialName).gltf" }
    var notes: String { "\(materialName)_SL_PBR_notes.txt" }
}

private struct NormalOutputs {
    let source: PixelImage
    let height: PixelImage
    let normal: PixelImage
    let roughness: PixelImage
    let gloss: PixelImage
    let metallic: PixelImage
    let occlusion: PixelImage
    let orm: PixelImage
}

private struct GeneratedSurfaceMaps {
    let roughness: PixelImage
    let gloss: PixelImage
    let metallic: PixelImage
    let occlusion: PixelImage
    let orm: PixelImage
}

private struct NormalOutputNames {
    let materialName: String

    var baseColor: String { "\(materialName)_basecolor.png" }
    var normal: String { "\(materialName)_normal.png" }
    var height: String { "\(materialName)_height.png" }
    var roughness: String { "\(materialName)_roughness.png" }
    var gloss: String { "\(materialName)_gloss.png" }
    var metallic: String { "\(materialName)_metallic.png" }
    var occlusion: String { "\(materialName)_ao.png" }
    var orm: String { "\(materialName)_orm.png" }
    var gltf: String { "\(materialName).gltf" }
    var notes: String { "\(materialName)_maps_notes.txt" }
}
