package org.openrndr.extra.camera

import org.openrndr.Extension
import org.openrndr.Program
import org.openrndr.draw.DepthTestPass
import org.openrndr.draw.Drawer
import org.openrndr.events.Event
import org.openrndr.math.Matrix44
import org.openrndr.math.Spherical
import org.openrndr.math.Vector3
import kotlin.math.abs
import kotlin.math.pow
import org.openrndr.math.transforms.lookAt as lookAt_

enum class ProjectionType {
    PERSPECTIVE,
    ORTHOGONAL
}


class OrbitalCamera(
    eye: Vector3 = Vector3.ZERO,
    lookAt: Vector3 = Vector3.UNIT_Z,
    var fov: Double = 90.0,
    var near: Double = 0.1,
    var far: Double = 1000.0,
    var projectionType: ProjectionType = ProjectionType.PERSPECTIVE
) : Extension, ChangeEvents {

    override val changed = Event<Unit>()

    override val hasChanged: Boolean
        get() = dirty


    // current position in spherical coordinates
    var spherical = Spherical.fromVector(eye)
        private set
    var lookAt = lookAt
        private set

    var depthTest = true

    var magnitude = 100.0
    var magnitudeEnd = magnitude

    private var sphericalEnd = Spherical.fromVector(eye)
    private var lookAtEnd = lookAt
    private var dirty: Boolean = true
        set(value) {
            if (value && !field) {
                changed.trigger(Unit)
            }
            field = value
        }
    private var lastSeconds: Double = -1.0

    var fovEnd = fov

    var dampingFactor = 0.05
    var zoomSpeed = 1.0

    var orthoNear = -1000.0
    var orthoFar = 1000.0

    fun setView(lookAt: Vector3, spherical: Spherical, fov: Double) {
        this.lookAt = lookAt
        this.lookAtEnd = lookAt
        this.spherical = spherical
        this.sphericalEnd = spherical
        this.fov = fov
        this.fovEnd = fov
    }

    fun rotate(rotX: Double, rotY: Double) {
        sphericalEnd += Spherical(rotX, rotY, 0.0)
        sphericalEnd = sphericalEnd.makeSafe()
        dirty = true
    }

    fun rotateTo(rotX: Double, rotY: Double) {
        sphericalEnd = sphericalEnd.copy(theta = rotX, phi = rotY)
        sphericalEnd = sphericalEnd.makeSafe()
        dirty = true
    }

    fun rotateTo(eye: Vector3) {
        sphericalEnd = Spherical.fromVector(eye)
        sphericalEnd = sphericalEnd.makeSafe()
        dirty = true
    }

    fun dollyIn() {
        val zoomScale = pow(0.95, zoomSpeed)
        dolly(sphericalEnd.radius * zoomScale - sphericalEnd.radius)
    }

    fun dollyOut() {
        val zoomScale = pow(0.95, zoomSpeed)
        dolly(sphericalEnd.radius / zoomScale - sphericalEnd.radius)
    }

    fun dolly(distance: Double) {
        sphericalEnd += Spherical(0.0, 0.0, distance)
        dirty = true
    }

    fun pan(x: Double, y: Double, z: Double) {
        val view = viewMatrix()
        val xColumn = Vector3(view.c0r0, view.c1r0, view.c2r0) * x
        val yColumn = Vector3(view.c0r1, view.c1r1, view.c2r1) * y
        val zColumn = Vector3(view.c0r2, view.c1r2, view.c2r2) * z
        lookAtEnd += xColumn + yColumn + zColumn
        dirty = true
    }

    fun panTo(target: Vector3) {
        lookAtEnd = target
        dirty = true
    }

    fun dollyTo(distance: Double) {
        sphericalEnd = sphericalEnd.copy(radius = distance)
        dirty = true
    }

    fun scale(s: Double) {
        magnitudeEnd += s
        dirty = true
    }

    fun scaleTo(s: Double) {
        magnitudeEnd = s
        dirty = true
    }

    fun zoom(degrees: Double) {
        fovEnd += degrees
        dirty = true
    }

    fun zoomTo(degrees: Double) {
        fovEnd = degrees
        dirty = true
    }

    fun update(timeDelta: Double) {
        if (!dirty) return
        dirty = false

        val dampingFactor = if (dampingFactor > 0.0) {
            dampingFactor * timeDelta / 0.0060
        } else 1.0
        val sphericalDelta = sphericalEnd - spherical
        val lookAtDelta = lookAtEnd - lookAt
        val fovDelta = fovEnd - fov
        val magnitudeDelta = magnitudeEnd - magnitude
        if (
            abs(sphericalDelta.radius) > EPSILON ||
            abs(sphericalDelta.theta) > EPSILON ||
            abs(sphericalDelta.phi) > EPSILON ||
            abs(lookAtDelta.x) > EPSILON ||
            abs(lookAtDelta.y) > EPSILON ||
            abs(lookAtDelta.z) > EPSILON ||
            abs(fovDelta) > EPSILON
        ) {
            fov += (fovDelta * dampingFactor)
            spherical += (sphericalDelta * dampingFactor)
            spherical = spherical.makeSafe()
            lookAt += (lookAtDelta * dampingFactor)
            magnitude += (magnitudeDelta * dampingFactor)
            dirty = true
        } else {
            magnitude = magnitudeEnd
            spherical = sphericalEnd.copy()
            lookAt = lookAtEnd.copy()
            fov = fovEnd
        }
        spherical = spherical.makeSafe()
    }

    fun viewMatrix(): Matrix44 {
        return lookAt_(Vector3.fromSpherical(spherical) + lookAt, lookAt, Vector3.UNIT_Y)
    }

    companion object {
        private const val EPSILON = 0.000001
    }

    // EXTENSION
    override var enabled: Boolean = true

    override fun beforeDraw(drawer: Drawer, program: Program) {

        drawer.pushTransforms()

        if (lastSeconds == -1.0) lastSeconds = program.seconds

        val delta = program.seconds - lastSeconds
        lastSeconds = program.seconds

        update(delta)
        applyTo(drawer)
    }

    override fun afterDraw(drawer: Drawer, program: Program) {
        drawer.popTransforms()
    }
}

/**
 * Temporarily enables this camera, calls function to draw using
 * that camera, then disables it by popping the last matrix changes.
 * It makes it easy to combine perspective and orthographic projections
 * in the same program.
 * @param function the function that is called in the isolation
 */
fun OrbitalCamera.isolated(drawer: Drawer, function: Drawer.() -> Unit) {
    drawer.pushTransforms()
    drawer.pushStyle()

    applyTo(drawer)
    function(drawer)

    drawer.popStyle()
    drawer.popTransforms()
}

/**
 * Enables the perspective camera. Use this faster method instead of .isolated()
 * if you don't need to revert back to the orthographic projection.
 */
fun OrbitalCamera.applyTo(drawer: Drawer) {
    if (projectionType == ProjectionType.PERSPECTIVE) {
        drawer.perspective(fov, drawer.width.toDouble() / drawer.height, near, far)
    } else {
        val ar = drawer.width * 1.0 / drawer.height
        drawer.ortho(-ar * magnitude, ar * magnitude, -1.0 * magnitude, 1.0 * magnitude, orthoNear, orthoFar)
    }
    drawer.view = viewMatrix()

    if (depthTest) {
        drawer.drawStyle.depthWrite = true
        drawer.drawStyle.depthTestPass = DepthTestPass.LESS_OR_EQUAL
    }
}

private fun pow(a: Double, x: Double): Double = a.pow(x)