import Foundation
import Metal
import CoreImage

protocol PassthroughTextureViewable: AnyObject {
    func texturePointForView(point: CGPoint) -> CGPoint?
    func viewPointForTexture(point: CGPoint) -> CGPoint?
}

class PassthroughRenderPass {
    
    enum Rotation: Int {
        case rotate0Degrees
        case rotate90Degrees
        case rotate180Degrees
        case rotate270Degrees
    }
    
    private var internalMirroring: Bool = false
    private var internalRotation: Rotation = .rotate0Degrees
    private var internalScale: Float = 1.0
    private var internalPixelBuffer: CVPixelBuffer?
    
    var mirroring = false {
        didSet {
            syncQueue.sync {
                internalMirroring = mirroring
            }
        }
    }
    
    var rotation: Rotation = .rotate0Degrees {
        didSet {
            syncQueue.sync {
                internalRotation = rotation
            }
        }
    }
    
    var scale: CGFloat = 1.0 {
        didSet {
            internalScale = Float(scale)
            needsTransformUpdate = true
        }
    }
    
    var pixelBuffer: CVPixelBuffer? {
        didSet {
            syncQueue.sync {
                internalPixelBuffer = pixelBuffer
            }
        }
    }
    
    private let syncQueue = DispatchQueue(
        label: "Preview View Sync Queue",
        qos: .userInitiated,
        attributes: [],
        autoreleaseFrequency: .workItem)
    
    private var textureCache: CVMetalTextureCache?
    private var textureWidth: Int = .zero
    private var textureHeight: Int = .zero
    private var textureMirroring: Bool = false
    private var textureRotation: Rotation = .rotate0Degrees
    private var needsTransformUpdate: Bool = false
    
    private var vertexCoordBuffer: MTLBuffer!
    private var textCoordBuffer: MTLBuffer!
    private var internalBounds: CGRect!
    private var textureTranform: CGAffineTransform?
    
    private var sampler: MTLSamplerState?
    private var renderPipelineState: MTLRenderPipelineState?
    
    var gpu: MTLDevice? {
        return Adamantium.sharedDevice
    }
    
    func setupRenderPass() {
        guard let gpu , let library = Adamantium.sharedLibrary else {
            print("can't configure pipeline descriptor")
            return
        }
        
        createTextureCache()
        
        let pipelineDescriptor = MTLRenderPipelineDescriptor()
        pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
        pipelineDescriptor.vertexFunction = library.makeFunction(name: "vertexPassThrough")
        pipelineDescriptor.fragmentFunction = library.makeFunction(name: "fragmentPassThrough")
        
        // To determine how textures are sampled, create a sampler descriptor to query for a sampler state from the device.
        let samplerDescriptor = MTLSamplerDescriptor()
        samplerDescriptor.sAddressMode = .clampToEdge
        samplerDescriptor.tAddressMode = .clampToEdge
        samplerDescriptor.minFilter = .linear
        samplerDescriptor.magFilter = .linear
        sampler = gpu.makeSamplerState(descriptor: samplerDescriptor)
        
        do {
            renderPipelineState = try gpu.makeRenderPipelineState(descriptor: pipelineDescriptor)
        } catch {
            fatalError("Unable to create preview Metal view pipeline state. (\(error))")
        }
    }
    
    func flushTextureCache() {
        if let textureCache {
            CVMetalTextureCacheFlush(textureCache, 0)
        }
        textureCache = nil
    }
    
    func render(encoder renderCommandEncoder: MTLRenderCommandEncoder, viewBounds: CGRect) {
        guard
            let cvPixelBuffer = pixelBuffer,
            let texture = setTransformsAndConvertCVBufferToTextureForRenderPass(cvPixelBuffer, viewBounds: viewBounds)
        else { return }
        
        guard let renderPipelineState else { return }
        renderCommandEncoder.setRenderPipelineState(renderPipelineState)
        renderCommandEncoder.setVertexBuffer(vertexCoordBuffer, offset: 0, index: 0)
        renderCommandEncoder.setVertexBuffer(textCoordBuffer, offset: 0, index: 1)
        renderCommandEncoder.setFragmentTexture(texture, index: 0)
        renderCommandEncoder.setFragmentSamplerState(sampler, index: 0)
        renderCommandEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
    }
}

extension PassthroughRenderPass: PassthroughTextureViewable {
    func texturePointForView(point: CGPoint) -> CGPoint? {
        var result: CGPoint?
        
        guard let transform = textureTranform else { return result }
        let transformPoint = point.applying(transform)
        
        if CGRect(
            origin: .zero,
            size: CGSize(
                width: textureWidth,
                height: textureHeight)
            )
            .contains(transformPoint) {
            result = transformPoint
        } else {
            print("Invalid point \(point) result point \(transformPoint)")
        }
        
        return result
    }
    
    func viewPointForTexture(point: CGPoint) -> CGPoint? {
        var result: CGPoint?
        guard let transform = textureTranform?.inverted() else { return result }
        let transformPoint = point.applying(transform)
        
        if internalBounds.contains(transformPoint) {
            result = transformPoint
        } else {
            print("Invalid point \(point) result point \(transformPoint)")
        }
        
        return result
    }
}

private extension PassthroughRenderPass {
    
    func createTextureCache() {
        guard let gpu else {
            print("can't create texture cache")
            return
        }
        var newTextureCache: CVMetalTextureCache?
        if CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, gpu, nil, &newTextureCache) == kCVReturnSuccess {
            textureCache = newTextureCache
        } else {
            assertionFailure("Unable to allocate texture cache")
        }
    }
    
    private func setTransform(
        viewBounds: CGRect,
        width: Int,
        height: Int,
        mirroring: Bool,
        rotation: Rotation,
        uniformScale: Float) {
        guard let gpu else {
            print("can't setup Transform")
            return
        }
        
        var scaleX: Float = 1.0
        var scaleY: Float = 1.0
        var resizeAspect: Float = 1.0
        
        internalBounds = viewBounds
        textureWidth = width
        textureHeight = height
        textureMirroring = mirroring
        textureRotation = rotation
        
        if textureWidth > 0 && textureHeight > 0 {
            switch textureRotation {
            case .rotate0Degrees,
                .rotate180Degrees:
                scaleX = Float(internalBounds.width / CGFloat(textureWidth))
                scaleY = Float(internalBounds.height / CGFloat(textureHeight))
                
            case .rotate90Degrees,
                .rotate270Degrees:
                scaleX = Float(internalBounds.width / CGFloat(textureHeight))
                scaleY = Float(internalBounds.height / CGFloat(textureWidth))
            }
        }
        
        // Resize Aspect Fit Scalar
        resizeAspect = min(scaleX, scaleY)
        
        // Aspect Fill Scalar
        var fillScalarAmount: Float = 1.0
        
        if scaleX < scaleY {
            fillScalarAmount = Float(textureHeight) / Float(internalBounds.height)
            scaleY = scaleX / scaleY
            scaleX = 1.0
        } else {
            fillScalarAmount = Float(textureWidth) / Float(internalBounds.width)
            scaleX = scaleY / scaleX
            scaleY = 1.0
        }
        
        fillScalarAmount *= uniformScale
        scaleX *= fillScalarAmount
        scaleY *= fillScalarAmount
        
        if textureMirroring {
            // Not X becuase the incoming image is rotated
            scaleY *= -1.0
        }
        
        // Vertex coordinate takes the gravity into account.
        let vertexData: [Float] = [
            -scaleX, -scaleY, 0.0, 1.0,
            scaleX, -scaleY, 0.0, 1.0,
            -scaleX, scaleY, 0.0, 1.0,
            scaleX, scaleY, 0.0, 1.0
        ]
        
        vertexCoordBuffer = gpu.makeBuffer(
            bytes: vertexData,
            length: vertexData.count * MemoryLayout<Float>.size, options: [])
        
        // Texture coordinate takes the rotation into account.
        var textData: [Float]
        switch textureRotation {
        case .rotate0Degrees:
            textData = [
                0.0, 1.0,
                1.0, 1.0,
                0.0, 0.0,
                1.0, 0.0
            ]
            
        case .rotate180Degrees:
            textData = [
                1.0, 0.0,
                0.0, 0.0,
                1.0, 1.0,
                0.0, 1.0
            ]
            
        case .rotate90Degrees:
            textData = [
                1.0, 1.0,
                1.0, 0.0,
                0.0, 1.0,
                0.0, 0.0
            ]
            
        case .rotate270Degrees:
            textData = [
                0.0, 0.0,
                0.0, 1.0,
                1.0, 0.0,
                1.0, 1.0
            ]
        }
        
        textCoordBuffer = gpu.makeBuffer(bytes: textData, length: textData.count * MemoryLayout<Float>.size, options: [])
        
        // Calculate the transform from texture coordinates to view coordinates
        var transform = CGAffineTransform.identity
        if textureMirroring {
            transform = transform.concatenating(CGAffineTransform(scaleX: -1, y: 1))
            transform = transform.concatenating(CGAffineTransform(translationX: CGFloat(textureWidth), y: 0))
        }
        
        switch textureRotation {
        case .rotate0Degrees:
            transform = transform.concatenating(CGAffineTransform(rotationAngle: CGFloat(0)))
            
        case .rotate180Degrees:
            transform = transform.concatenating(CGAffineTransform(rotationAngle: CGFloat(Double.pi)))
            transform = transform.concatenating(CGAffineTransform(translationX: CGFloat(textureWidth), y: CGFloat(textureHeight)))
            
        case .rotate90Degrees:
            transform = transform.concatenating(CGAffineTransform(rotationAngle: CGFloat(Double.pi) / 2))
            transform = transform.concatenating(CGAffineTransform(translationX: CGFloat(textureHeight), y: 0))
            
        case .rotate270Degrees:
            transform = transform.concatenating(CGAffineTransform(rotationAngle: 3 * CGFloat(Double.pi) / 2))
            transform = transform.concatenating(CGAffineTransform(translationX: 0, y: CGFloat(textureWidth)))
        }
        
        transform = transform.concatenating(CGAffineTransform(scaleX: CGFloat(resizeAspect), y: CGFloat(resizeAspect)))
        let tranformRect = CGRect(origin: .zero, size: CGSize(width: textureWidth, height: textureHeight)).applying(transform)
        let xShift = (internalBounds.size.width - tranformRect.size.width) / 2
        let yShift = (internalBounds.size.height - tranformRect.size.height) / 2
        transform = transform.concatenating(CGAffineTransform(translationX: xShift, y: yShift))
        textureTranform = transform.inverted()
    }
    
    func setTransformsAndConvertCVBufferToTextureForRenderPass(
        _ pixelBuffer: CVPixelBuffer?, viewBounds: CGRect) -> MTLTexture? {
        
        var pixelBuffer: CVPixelBuffer?
        var mirroring = false
        var rotation: Rotation = .rotate0Degrees
        var scale: Float = 1.0
        
        syncQueue.sync {
            pixelBuffer = internalPixelBuffer
            mirroring = internalMirroring
            rotation = internalRotation
            scale = internalScale
        }
            
        guard let previewPixelBuffer = pixelBuffer else { return nil }
        
        // Create a Metal texture from the image buffer.
        let width = CVPixelBufferGetWidth(previewPixelBuffer)
        let height = CVPixelBufferGetHeight(previewPixelBuffer)
        
        if textureCache == nil {
            createTextureCache()
        }
        
        var cvTextureOut: CVMetalTexture?
        
        CVMetalTextureCacheCreateTextureFromImage(
            kCFAllocatorDefault,
            textureCache!,
            previewPixelBuffer,
            nil,
            .bgra8Unorm,
            width,
            height,
            0,
            &cvTextureOut)
        
        guard let cvTexture = cvTextureOut,
            let texture = CVMetalTextureGetTexture(cvTexture) else {
            print("Failed to create preview texture")
            CVMetalTextureCacheFlush(textureCache!, 0)
            return nil
        }
            
        if needsTransformUpdate ||
            texture.width != textureWidth ||
            texture.height != textureHeight ||
            viewBounds != internalBounds ||
            mirroring != textureMirroring ||
            rotation != textureRotation {
            
            needsTransformUpdate = false
            setTransform(
                viewBounds: viewBounds,
                width: texture.width,
                height: texture.height,
                mirroring: mirroring,
                rotation: rotation,
                uniformScale: scale)
        }
        
        return texture
    }
}
