// Copyright 2020-2021 Kevin Reid under the terms of the MIT License as detailed
// in the accompanying file README.md or <https://opensource.org/licenses/MIT>.

use cgmath::{EuclideanSpace as _, Point2, Vector2, Vector3, Zero as _};
use std::collections::{HashMap, HashSet};
use std::time::Duration;

use crate::apps::Tick;
use crate::camera::Viewport;
use crate::character::Character;
use crate::listen::{ListenableCell, ListenableSource};
use crate::math::FreeCoordinate;

/// Parse input events, particularly key-down/up pairs, into character control and such.
///
/// This is designed to be a leaf of the dependency graph: it does not own or send
/// messages to any other elements of the application. Instead, the following steps
/// must occur in the given order.
///
/// 1. The platform-specific code should call [`InputProcessor::key_down`] and such to
///    to provide input information.
/// 2. The game loop should call [`InputProcessor::apply_input`] to apply the effects
///    of input on the relevant [`Character`].
/// 3. The game loop should call [`InputProcessor::step`] to apply the effects of time
///    on the input processor.
#[derive(Debug)]
pub struct InputProcessor {
    /// All [`Key`]s currently pressed.
    keys_held: HashSet<Key>,
    /// As a special feature for supporting input without key-up events, stores all
    /// keypresses arriving through [`Self::key_momentary`] and virtually holds them
    /// for a short time. The value is the remaining time.
    momentary_timeout: HashMap<Key, Duration>,
    /// [`Key`]s with one-shot effects when pressed which need to be applied
    /// once per press rather than while held.
    command_buffer: Vec<Key>,

    /// Do we *want* pointer lock for mouselook?
    ///
    /// This is listenable so that the UI can react to this state.
    mouselook_mode: ListenableCell<bool>,
    /// Do we *have* pointer lock for mouselook? Reported by calling input implementation.
    has_pointer_lock: bool,

    /// Net mouse movement since the last [`Self::apply_input`].
    mouselook_buffer: Vector2<FreeCoordinate>,

    /// Mouse position in NDC. None if out of bounds/lost focus.
    mouse_ndc_position: Option<Point2<FreeCoordinate>>,

    /// Mouse position used for generating mouselook deltas.
    /// [`None`] if games.
    mouse_previous_pixel_position: Option<Point2<f64>>,
}

impl InputProcessor {
    #[allow(clippy::new_without_default)] // I expect it'll grow some parameters
    pub fn new() -> Self {
        Self {
            keys_held: HashSet::new(),
            momentary_timeout: HashMap::new(),
            command_buffer: Vec::new(),
            mouselook_mode: ListenableCell::new(false), // TODO: might want a parameter
            has_pointer_lock: false,
            mouselook_buffer: Vector2::zero(),
            mouse_ndc_position: Some(Point2::origin()),
            mouse_previous_pixel_position: None,
        }
    }

    fn is_bound(key: Key) -> bool {
        // Eventually we'll have actual configurable keybindings...
        match key {
            // Used in `InputProcessor::movement()`.
            Key::Character('w') => true,
            Key::Character('a') => true,
            Key::Character('s') => true,
            Key::Character('d') => true,
            Key::Character('e') => true,
            Key::Character('c') => true,
            // Used in `InputProcessor::apply_input()`.
            Key::Left => true,
            Key::Right => true,
            Key::Up => true,
            Key::Down => true,
            Key::Character(' ') => true,
            Key::Character(d) if d.is_ascii_digit() => true,
            Key::Character('l') => true,
            Key::Character('p') => true,
            _ => false,
        }
    }

    /// Returns true if the key should go in `command_buffer`.
    fn is_command(key: Key) -> bool {
        #[allow(clippy::match_like_matches_macro)]
        match key {
            Key::Character(d) if d.is_ascii_digit() => true,
            Key::Character('l') => true,
            Key::Character('p') => true,
            // TODO: move slot selection commands here
            _ => false,
        }
    }

    /// Handles incoming key-down events. Returns whether the key was unbound.
    pub fn key_down(&mut self, key: Key) -> bool {
        let bound = Self::is_bound(key);
        if bound {
            self.keys_held.insert(key);
            if Self::is_command(key) {
                self.command_buffer.push(key);
            }
        }
        bound
    }

    /// Handles incoming key-up events.
    pub fn key_up(&mut self, key: Key) {
        self.keys_held.remove(&key);
    }

    /// Handles incoming key events in the case where key-up events are not available,
    /// such that an assumption about equivalent press duration must be made.
    pub fn key_momentary(&mut self, key: Key) -> bool {
        self.momentary_timeout
            .insert(key, Duration::from_millis(200));
        self.key_up(key);
        self.key_down(key)
    }

    /// Handles the keyboard focus being gained or lost. If the platform does not have
    /// a concept of focus, you need not call this method, but may call it with `true`.
    ///
    /// `InputProcessor` will assume that if focus is lost, key-up events may be lost and
    /// so currently held keys should stop taking effect.
    pub fn key_focus(&mut self, has_focus: bool) {
        if has_focus {
            // Nothing to do.
        } else {
            self.keys_held.clear();
            self.momentary_timeout.clear();

            self.mouselook_mode.set(false);
        }
    }

    /// True when the UI is in a state which _should_ have mouse pointer
    /// lock/capture/disable. This is not the same as actually having it since the window
    /// may lack focus, the application may lack permission, etc.; use
    /// [`InputProcessor::has_pointer_lock`] to report that state.
    pub fn wants_pointer_lock(&self) -> bool {
        *self.mouselook_mode.get()
    }

    /// Use this method to report whether mouse mouse pointer lock/capture/disable is
    /// known to be successfully enabled, after [`InputProcessor::wants_pointer_lock`]
    /// requests it or it is disabled for any reason.
    pub fn has_pointer_lock(&mut self, value: bool) {
        self.has_pointer_lock = value;
    }

    /// Provide relative movement information for mouselook.
    ///
    /// This value is an accumulated displacement, not an angular velocity, so it is not
    /// suitable for joystick-type input.
    ///
    /// Note that absolute cursor positions must be provided separately.
    pub fn mouselook_delta(&mut self, delta: Vector2<FreeCoordinate>) {
        // TODO: sensitivity option
        if self.has_pointer_lock {
            self.mouselook_buffer += delta * 0.2;
        }
    }

    /// Provide position of mouse pointer or other input device in normalized device
    /// coordinates (range -1 to 1 upward and rightward).
    /// [`None`] denotes the cursor being outside the viewport, and out-of-range
    /// coordinates will be treated the same.
    ///
    /// Pixel coordinates may be converted to NDC using [`Viewport::normalize_nominal_point`]
    /// or by using [`InputProcessor::mouse_pixel_position`].
    ///
    /// If this is never called, the default value is (0, 0) which corresponds to the
    /// center of the screen.
    pub fn mouse_ndc_position(&mut self, position: Option<Point2<FreeCoordinate>>) {
        self.mouse_ndc_position = position.filter(|p| p.x.abs() <= 1. && p.y.abs() <= 1.);
    }

    /// Provide position of mouse pointer or other input device in pixel coordinates
    /// framed by the given [`Viewport`].
    /// [`None`] denotes the cursor being outside the viewport, and out-of-range
    /// coordinates will be treated the same.
    ///
    /// This is equivalent to converting the coordinates and calling
    /// [`InputProcessor::mouse_ndc_position`].
    ///
    /// If this is never called, the default value is (0, 0) which corresponds to the
    /// center of the screen.
    ///
    /// TODO: this should take float input, probably
    pub fn mouse_pixel_position(
        &mut self,
        viewport: Viewport,
        position: Option<Point2<f64>>,
        derive_movement: bool,
    ) {
        self.mouse_ndc_position(
            position.map(|p| Point2::from_vec(viewport.normalize_nominal_point(p))),
        );

        if derive_movement {
            if let (Some(p1), Some(p2)) = (self.mouse_previous_pixel_position, position) {
                self.mouselook_delta((p2 - p1).map(FreeCoordinate::from));
            }
            self.mouse_previous_pixel_position = position;
        } else {
            self.mouse_previous_pixel_position = None;
        }
    }

    /// Returns the character movement velocity that input is currently requesting.
    pub fn movement(&self) -> Vector3<FreeCoordinate> {
        Vector3::new(
            self.net_movement(Key::Character('a'), Key::Character('d')),
            self.net_movement(Key::Character('c'), Key::Character('e')),
            self.net_movement(Key::Character('w'), Key::Character('s')),
        )
    }

    /// Advance time insofar as input interpretation is affected by time.
    ///
    /// This method should be called *after* [`apply_input`](Self::apply_input), when
    /// applicable.
    pub fn step(&mut self, tick: Tick) {
        let mut to_drop = Vec::new();
        for (key, duration) in self.momentary_timeout.iter_mut() {
            if let Some(reduced) = duration.checked_sub(tick.delta_t) {
                *duration = reduced;
            } else {
                to_drop.push(*key);
            }
        }
        for key in to_drop.drain(..) {
            self.momentary_timeout.remove(&key);
            self.key_up(key);
        }

        self.mouselook_buffer = Vector2::zero();
    }

    /// Applies the current input to the given [`Character`].
    ///
    /// TODO: We need a better information flow strategy that still keeps InputProcessor not too tied to AllIsCubesAppState.
    pub fn apply_input(
        &mut self,
        character: &mut Character,
        paused: &ListenableCell<bool>,
        tick: Tick,
    ) {
        let dt = tick.delta_t.as_secs_f64();
        let key_turning_step = 80.0 * dt;

        let movement = self.movement();
        character.set_velocity_input(movement);

        let turning = Vector2::new(
            key_turning_step * self.net_movement(Key::Left, Key::Right) + self.mouselook_buffer.x,
            key_turning_step * self.net_movement(Key::Up, Key::Down) + self.mouselook_buffer.y,
        );
        character.body.yaw = (character.body.yaw + turning.x).rem_euclid(360.0);
        character.body.pitch = (character.body.pitch + turning.y).min(90.0).max(-90.0);

        if self.keys_held.contains(&Key::Character(' ')) {
            character.jump_if_able();
        }

        for key in self.command_buffer.drain(..) {
            match key {
                Key::Character('l') => {
                    let new_state = !*self.mouselook_mode.get();
                    self.mouselook_mode.set(new_state);
                    if new_state {
                        // Clear delta tracking just in case
                        self.mouse_previous_pixel_position = None;
                    }
                }
                Key::Character('p') => {
                    // TODO: bind escape key, focus loss, etc to pause
                    paused.set(!*paused.get());
                }
                Key::Character(numeral) if numeral.is_digit(10) => {
                    let digit = numeral.to_digit(10).unwrap() as usize;
                    let slot = (digit + 9).rem_euclid(10); // wrap 0 to 9
                    character.set_selected_slot(1, slot);
                }
                _ => {}
            }
        }
    }

    pub fn mouselook_mode(&self) -> ListenableSource<bool> {
        self.mouselook_mode.as_source()
    }

    /// Returns the position which should be used for click/cursor raycasting.
    /// This is not necessarily equal to the tracked mouse position.
    ///
    /// Returns [`None`] if the mouse position is out of bounds, the window has lost
    /// focus, or similar conditions under which no cursor should be shown.
    pub fn cursor_ndc_position(&self) -> Option<Point2<FreeCoordinate>> {
        if *self.mouselook_mode.get() {
            Some(Point2::origin())
        } else {
            self.mouse_ndc_position
        }
    }

    /// Computes the net effect of a pair of opposed inputs (e.g. "forward" and "back").
    fn net_movement(&self, negative: Key, positive: Key) -> FreeCoordinate {
        match (
            self.keys_held.contains(&negative),
            self.keys_held.contains(&positive),
        ) {
            (true, false) => -1.0,
            (false, true) => 1.0,
            _ => 0.0,
        }
    }
}

/// A platform-neutral representation of keyboard keys for [`InputProcessor`].
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
#[non_exhaustive]
pub enum Key {
    /// Letters should be lowercase.
    Character(char),
    /// Left arrow key.
    Left,
    /// Right arrow key.
    Right,
    /// Up arrow key.
    Up,
    /// Down arrow key.
    Down,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::space::Space;
    use crate::universe::Universe;

    #[test]
    fn movement() {
        let mut input = InputProcessor::new();
        assert_eq!(input.movement(), Vector3::new(0.0, 0.0, 0.0));
        input.key_down(Key::Character('d'));
        assert_eq!(input.movement(), Vector3::new(1.0, 0.0, 0.0));
        input.key_down(Key::Character('a'));
        assert_eq!(input.movement(), Vector3::new(0.0, 0.0, 0.0));
        input.key_up(Key::Character('d'));
        assert_eq!(input.movement(), Vector3::new(-1.0, 0.0, 0.0));
    }

    #[test]
    fn focus_lost_cancels_keys() {
        let mut input = InputProcessor::new();
        assert_eq!(input.movement(), Vector3::zero());
        input.key_down(Key::Character('d'));
        assert_eq!(input.movement(), Vector3::unit_x());
        input.key_focus(false);
        assert_eq!(input.movement(), Vector3::zero()); // Lost focus, no movement.

        // Confirm that keys work again afterward.
        input.key_focus(true);
        assert_eq!(input.movement(), Vector3::zero());
        input.key_down(Key::Character('d'));
        assert_eq!(input.movement(), Vector3::unit_x());
        // TODO: test (and handle) key events arriving while focus is lost, just in case.
    }

    #[test]
    fn slot_selection() {
        // TODO: Awful lot of setup boilerplate...
        let mut u = Universe::new();
        let space = u.insert_anonymous(Space::empty_positive(1, 1, 1));
        let character = u.insert_anonymous(Character::spawn_default(space.clone()));
        let mut input = InputProcessor::new();

        input.key_down(Key::Character('5'));
        input.key_up(Key::Character('5'));
        input.apply_input(
            &mut *character.borrow_mut(),
            &ListenableCell::new(false),
            Tick::arbitrary(),
        );
        assert_eq!(character.borrow_mut().selected_slots()[1], 4);

        // Tenth slot
        input.key_down(Key::Character('0'));
        input.key_up(Key::Character('0'));
        input.apply_input(
            &mut *character.borrow_mut(),
            &ListenableCell::new(false),
            Tick::arbitrary(),
        );
        assert_eq!(character.borrow_mut().selected_slots()[1], 9);
    }

    // TODO: test jump and flying logic
}
