import math
import numpy as np
import vector

def clamp(val, lowest, highest):
    return min(highest, max(lowest, val))

class Camera:
    
    # good starting values
    start_x,start_y = 0,0
    start_zoom = 2.5
    x_tilt, y_tilt = 0, 0
    # pan/zoom speed tuning
    mouse_pan_rate = 10
    pan_accel = 0.005
    base_max_pan_speed = 0.8
    pan_friction = 0.1
    # min/max zoom % between which pan speed variation scales
    pan_min_pct = 25.0
    pan_max_pct = 200.0
    # factor by which zoom level modifies pan speed
    pan_zoom_increase_factor = 16
    zoom_accel = 0.1
    max_zoom_speed = 2.5
    zoom_friction = 0.1
    # kill velocity if below this
    min_velocity = 0.05
    # map extents
    # starting values only, bounds are generated according to art size
    min_x,max_x = -10, 50
    min_y,max_y = -50, 10
    use_bounds = True
    min_zoom,max_zoom = 1, 1000
    # matrices -> worldspace renderable vertex shader uniforms
    fov = 90
    near_z = 0.0001
    far_z = 100000
    
    def __init__(self, app):
        self.app = app
        self.reset()
        self.max_pan_speed = self.base_max_pan_speed
    
    def reset(self):
        self.x, self.y = self.start_x, self.start_y
        self.z = self.start_zoom
        # store look vectors so world/screen space conversions can refer to it
        self.look_x, self.look_y, self.look_z = None,None,None
        self.vel_x, self.vel_y, self.vel_z = 0,0,0
        self.mouse_panned, self.moved_this_frame = False, False
        # GameObject to focus on
        self.focus_object = None
        self.calc_projection_matrix()
        self.calc_view_matrix()
    
    def calc_projection_matrix(self):
        self.projection_matrix = self.get_perspective_matrix()
    
    def calc_view_matrix(self):
        eye = vector.Vec3(self.x, self.y, self.z)
        up = vector.Vec3(0, 1, 0)
        target = vector.Vec3(eye.x + self.x_tilt, eye.y + self.y_tilt, 0)
        # view axes
        forward = (target - eye).normalize()
        side = forward.cross(up).normalize()
        upward = side.cross(forward)
        m = [[side.x, upward.x, -forward.x, 0],
             [side.y, upward.y, -forward.y, 0],
             [side.z, upward.z, -forward.z, 0],
             [-eye.dot(side), -eye.dot(upward), eye.dot(forward), 1]]
        self.view_matrix = np.array(m, dtype=np.float32)
        self.look_x, self.look_y, self.look_z = side, upward, forward
    
    def get_perspective_matrix(self):
        zmul = (-2 * self.near_z * self.far_z) / (self.far_z - self.near_z)
        ymul = 1 / math.tan(self.fov * math.pi / 360)
        aspect = self.app.window_width / self.app.window_height
        xmul = ymul / aspect
        m = [[xmul,    0,    0,  0],
             [   0, ymul,    0,  0],
             [   0,    0,   -1, -1],
             [   0,    0, zmul,  0]]
        return np.array(m, dtype=np.float32)
    
    def get_ortho_matrix(self, width=None, height=None):
        width, height = width or self.app.window_width, height or self.app.window_height
        m = np.eye(4, 4, dtype=np.float32)
        left, bottom = 0, 0
        right, top = width, height
        far_z, near_z = -1, 1
        x = 2 / (right - left)
        y = 2 / (top - bottom)
        z = -2 / (self.far_z - self.near_z)
        wx = -(right + left) / (right - left)
        wy = -(top + bottom) / (top - bottom)
        wz = -(self.far_z + self.near_z) / (self.far_z - self.near_z)
        m = [[ x,  0,  0, 0],
             [ 0,  y,  0, 0],
             [ 0,  0,  z, 0],
             [wx, wy, wz, 0]]
        return np.array(m, dtype=np.float32)
    
    def pan(self, dx, dy, keyboard=False):
        # modify pan speed based on zoom according to a factor
        m = (self.pan_zoom_increase_factor * self.z) / self.min_zoom
        self.vel_x += dx * self.pan_accel * m
        self.vel_y += dy * self.pan_accel * m
        # for brevity, app passes in whether user appears to be keyboard editing
        if keyboard:
            self.app.keyboard_editing = True
    
    def zoom(self, dz, keyboard=False, towards_cursor=False):
        self.vel_z += dz * self.zoom_accel
        # pan towards cursor while zooming?
        if towards_cursor:
            dx = self.app.cursor.x - self.x
            dy = self.app.cursor.y - self.y
            self.pan(dx, dy, keyboard)
        if keyboard:
            self.app.keyboard_editing = True
    
    def get_current_zoom_pct(self):
        "returns % of base (1:1) for current camera"
        return (self.get_base_zoom() / self.z) * 100
    
    def get_base_zoom(self):
        "returns camera Z needed for 1:1 pixel zoom"
        wh = self.app.window_height
        ch = self.app.ui.active_art.charset.char_height
        # TODO: understand why this produces correct result for 8x8 charsets
        if ch == 8:
            ch = 16
        return wh / ch
    
    def set_to_base_zoom(self):
        self.z = self.get_base_zoom()
    
    def zoom_proportional(self, direction):
        "zooms in or out via increments of 1:1 pixel scales for active art"
        if not self.app.ui.active_art:
            return
        self.app.ui.active_art.camera_zoomed_extents = False
        base_zoom = self.get_base_zoom()
        # build span of all 1:1 zoom increments
        zooms = []
        m = 1
        while base_zoom / m > self.min_zoom:
            zooms.append(base_zoom / m)
            m *= 2
        zooms.reverse()
        m = 1
        while base_zoom * m < self.max_zoom:
            zooms.append(base_zoom * m)
            m *= 2
        # set zoom to nearest increment in direction we're heading
        if direction > 0:
            zooms.reverse()
            for zoom in zooms:
                if self.z > zoom:
                    self.z = zoom
                    break
        elif direction < 0:
            for zoom in zooms:
                if self.z < zoom:
                    self.z = zoom
                    break
        # kill all Z velocity for camera so we don't drift out of 1:1
        self.vel_z = 0
    
    def find_closest_zoom_extents(self):
        def corners_on_screen():
            art = self.app.ui.active_art
            z = art.layers_z[-1]
            x1, y1 = art.renderables[0].x, art.renderables[0].y
            left, top = vector.world_to_screen_normalized(self.app, x1, y1, z)
            x2 = x1 + art.width * art.quad_width
            y2 = y1 - art.height * art.quad_height
            right, bot = vector.world_to_screen_normalized(self.app, x2, y2, z)
            #print('(%.3f, %.3f) -> (%.3f, %.3f)' % (left, top, right, bot))
            # add 1 tile of UI chars to top and bottom margins
            top_margin = 1 - self.app.ui.menu_bar.art.quad_height
            bot_margin = -1 + self.app.ui.status_bar.art.quad_height
            return left >= -1 and top <= top_margin and \
                right <= 1 and bot >= bot_margin
        # zoom out from minimum until all corners are visible
        self.z = self.min_zoom
        # recalc view matrix each move so projection stays correct
        self.calc_view_matrix()
        tries = 0
        while not corners_on_screen() and tries < 30:
            self.zoom_proportional(-1)
            self.calc_view_matrix()
            tries += 1
    
    def toggle_zoom_extents(self, override=None):
        art = self.app.ui.active_art
        if override is not None:
            art.camera_zoomed_extents = not override
        if art.camera_zoomed_extents:
            # restore cached position
            self.x, self.y, self.z = art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z
        else:
            art.non_extents_camera_x, art.non_extents_camera_y, art.non_extents_camera_z = self.x, self.y, self.z
            # center camera on art
            self.x = (art.width * art.quad_width) / 2
            self.y = -(art.height * art.quad_height) / 2
            self.find_closest_zoom_extents()
        # kill all camera velocity when snapping
        self.vel_x, self.vel_y, self.vel_z = 0, 0, 0
        art.camera_zoomed_extents = not art.camera_zoomed_extents
    
    def window_resized(self):
        self.calc_projection_matrix()
    
    def set_zoom(self, z):
        # TODO: set lerp target, clear if keyboard etc call zoom()
        self.z = z
    
    def set_loc(self, x, y, z):
        self.x, self.y, self.z = x, y, (z or self.z) # z optional
    
    def set_loc_from_obj(self, game_object):
        self.set_loc(game_object.x, game_object.y, game_object.z)
    
    def set_for_art(self, art):
        # set limits
        self.max_x = art.width * art.quad_width
        self.min_y = -art.height * art.quad_height
        # use saved pan/zoom
        self.set_loc(art.camera_x, art.camera_y, art.camera_z)
    
    def mouse_pan(self, dx, dy):
        "pan view based on mouse delta"
        if dx == 0 and dy == 0:
            return
        m = ((1 * self.pan_zoom_increase_factor) * self.z) / self.min_zoom
        m /= self.max_zoom
        self.x -= dx / self.mouse_pan_rate * m
        self.y += dy / self.mouse_pan_rate * m
        self.vel_x = self.vel_y = 0
        self.mouse_panned = True
    
    def update(self):
        # zoom-proportional pan scale is based on art
        if self.app.ui.active_art:
            speed_scale = clamp(self.get_current_zoom_pct(),
                                self.pan_min_pct, self.pan_max_pct)
            self.max_pan_speed = self.base_max_pan_speed / (speed_scale / 100)
        else:
            self.max_pan_speed = self.base_max_pan_speed
        # remember last position to see if it changed
        self.last_x, self.last_y, self.last_z = self.x, self.y, self.z
        # if focus object is set, use it for X and Y transforms
        if self.focus_object:
            # track towards target
            # TODO: revisit this for better feel later
            dx, dy = self.focus_object.x - self.x, self.focus_object.y - self.y
            l = math.sqrt(dx ** 2 + dy ** 2)
            if l != 0 and l > 0.1:
                il = 1 / l
                dx *= il
                dy *= il
                self.x += dx * self.pan_friction
                self.y += dy * self.pan_friction
        else:
            # clamp velocity
            self.vel_x = clamp(self.vel_x, -self.max_pan_speed, self.max_pan_speed)
            self.vel_y = clamp(self.vel_y, -self.max_pan_speed, self.max_pan_speed)
            # apply friction
            self.vel_x *= 1 - self.pan_friction
            self.vel_y *= 1 - self.pan_friction        
            if abs(self.vel_x) < self.min_velocity:
                self.vel_x = 0
            if abs(self.vel_y) < self.min_velocity:
                self.vel_y = 0
            # if camera moves, we're not in zoom-extents state anymore
            if self.app.ui.active_art and (self.vel_x or self.vel_y):
                self.app.ui.active_art.camera_zoomed_extents = False
            # move
            self.x += self.vel_x
            self.y += self.vel_y
        # process Z separately
        self.vel_z = clamp(self.vel_z, -self.max_zoom_speed, self.max_zoom_speed)
        self.vel_z *= 1 - self.zoom_friction
        if abs(self.vel_z) < self.min_velocity:
            self.vel_z = 0
        # as bove, if zooming turn off zoom-extents state
        if self.vel_z and self.app.ui.active_art:
            self.app.ui.active_art.camera_zoomed_extents = False
        self.z += self.vel_z
        # keep within bounds
        if self.use_bounds:
            self.x = clamp(self.x, self.min_x, self.max_x)
            self.y = clamp(self.y, self.min_y, self.max_y)
            self.z = clamp(self.z, self.min_zoom, self.max_zoom)
        # set view matrix from xyz
        self.calc_view_matrix()
        self.moved_this_frame = self.mouse_panned or self.x != self.last_x or self.y != self.last_y or self.z != self.last_z
        self.mouse_panned = False
    
    def log_loc(self):
        self.app.log('camera x=%s, y=%s, z=%s' % (self.x, self.y, self.z))
