// Support for exposing the terminal size.
use crate::common::assert_sync;
use crate::env::{EnvMode, EnvVar, Environment};
use crate::flog::FLOG;
use crate::parser::Parser;
#[cfg(test)]
use crate::tests::prelude::*;
use crate::wchar::prelude::*;
use crate::wutil::fish_wcstoi;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Mutex;

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Termsize {
    /// Width of the terminal, in columns.
    // TODO: Change to u32
    pub width: isize,

    /// Height of the terminal, in rows.
    // TODO: Change to u32
    pub height: isize,
}

// A counter which is incremented every SIGWINCH, or when the tty is otherwise invalidated.
static TTY_TERMSIZE_GEN_COUNT: AtomicU32 = AtomicU32::new(0);

/// Convert an environment variable to an int, or return a default value.
/// The int must be >0 and <USHRT_MAX (from struct winsize).
fn var_to_int_or(var: Option<EnvVar>, default: isize) -> isize {
    let val: WString = var.map(|v| v.as_string()).unwrap_or_default();
    if !val.is_empty() {
        if let Ok(proposed) = fish_wcstoi(&val) {
            if proposed > 0 && proposed <= u16::MAX as i32 {
                return proposed as isize;
            }
        }
    }
    default
}

/// \return a termsize from ioctl, or None on error or if not supported.
fn read_termsize_from_tty() -> Option<Termsize> {
    let mut ret: Option<Termsize> = None;
    // Note: historically we've supported libc::winsize not existing.
    let mut winsize: libc::winsize = unsafe { std::mem::zeroed() };
    if unsafe { libc::ioctl(0, libc::TIOCGWINSZ, &mut winsize as *mut libc::winsize) } >= 0 {
        // 0 values are unusable, fall back to the default instead.
        if winsize.ws_col == 0 {
            FLOG!(
                term_support,
                L!("Terminal has 0 columns, falling back to default width")
            );
            winsize.ws_col = Termsize::DEFAULT_WIDTH as u16;
        }
        if winsize.ws_row == 0 {
            FLOG!(
                term_support,
                L!("Terminal has 0 rows, falling back to default height")
            );
            winsize.ws_row = Termsize::DEFAULT_HEIGHT as u16;
        }
        ret = Some(Termsize::new(
            winsize.ws_col as isize,
            winsize.ws_row as isize,
        ));
    }
    ret
}

impl Termsize {
    /// Default width and height.
    pub const DEFAULT_WIDTH: isize = 80;
    pub const DEFAULT_HEIGHT: isize = 24;

    /// Construct from width and height.
    pub fn new(width: isize, height: isize) -> Self {
        Self { width, height }
    }

    /// Return a default-sized termsize.
    pub fn defaults() -> Self {
        Self::new(Self::DEFAULT_WIDTH, Self::DEFAULT_HEIGHT)
    }
}

struct TermsizeData {
    // The last termsize returned by TIOCGWINSZ, or none if none.
    last_from_tty: Option<Termsize>,
    // The last termsize seen from the environment (COLUMNS/LINES), or none if none.
    last_from_env: Option<Termsize>,
    // The last-seen tty-invalidation generation count.
    // Set to a huge value so it's initially stale.
    last_tty_gen_count: u32,
}

impl TermsizeData {
    const fn defaults() -> Self {
        Self {
            last_from_tty: None,
            last_from_env: None,
            last_tty_gen_count: u32::max_value(),
        }
    }

    /// \return the current termsize from this data.
    fn current(&self) -> Termsize {
        // This encapsulates our ordering logic. If we have a termsize from a tty, use it; otherwise use
        // what we have seen from the environment.
        self.last_from_tty
            .or(self.last_from_env)
            .unwrap_or_else(Termsize::defaults)
    }

    /// Mark that our termsize is (for the time being) from the environment, not the tty.
    fn mark_override_from_env(&mut self, ts: Termsize) {
        self.last_from_env = Some(ts);
        self.last_from_tty = None;
        self.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
    }
}

/// Termsize monitoring is more complicated than one may think.
/// The main source of complexity is the interaction between the environment variables COLUMNS/ROWS,
/// the WINCH signal, and the TIOCGWINSZ ioctl.
/// Our policy is "last seen wins": if COLUMNS or LINES is modified, we respect that until we get a
/// SIGWINCH.
pub struct TermsizeContainer {
    // Our lock-protected data.
    data: Mutex<TermsizeData>,

    // An indication that we are currently in the process of setting COLUMNS and LINES, and so do
    // not react to any changes.
    setting_env_vars: AtomicBool,

    /// A function used for accessing the termsize from the tty. This is only exposed for testing.
    tty_size_reader: fn() -> Option<Termsize>,
}

impl TermsizeContainer {
    /// \return the termsize without applying any updates.
    /// Return the default termsize if none.
    pub fn last(&self) -> Termsize {
        self.data.lock().unwrap().current()
    }

    /// Initialize our termsize, using the given environment stack.
    /// This will prefer to use COLUMNS and LINES, but will fall back to the tty size reader.
    /// This does not change any variables in the environment.
    pub fn initialize(&self, vars: &dyn Environment) -> Termsize {
        let new_termsize = Termsize {
            width: var_to_int_or(vars.getf(L!("COLUMNS"), EnvMode::GLOBAL), -1),
            height: var_to_int_or(vars.getf(L!("LINES"), EnvMode::GLOBAL), -1),
        };

        let mut data = self.data.lock().unwrap();
        if new_termsize.width > 0 && new_termsize.height > 0 {
            data.mark_override_from_env(new_termsize);
        } else {
            data.last_tty_gen_count = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
            data.last_from_tty = (self.tty_size_reader)();
        }
        data.current()
    }

    /// If our termsize is stale, update it, using \p parser to fire any events that may be
    /// registered for COLUMNS and LINES.
    /// This requires a shared reference so it can work from a static.
    /// \return the updated termsize.
    pub fn updating(&self, parser: &Parser) -> Termsize {
        let new_size;
        let prev_size;

        // Take the lock in a local region.
        // Capture the size before and the new size.
        {
            let mut data = self.data.lock().unwrap();
            prev_size = data.current();

            // Critical read of signal-owned variable.
            // This must happen before the TIOCGWINSZ ioctl.
            let tty_gen_count: u32 = TTY_TERMSIZE_GEN_COUNT.load(Ordering::Relaxed);
            if data.last_tty_gen_count != tty_gen_count {
                // Our idea of the size of the terminal may be stale.
                // Apply any updates.
                data.last_tty_gen_count = tty_gen_count;
                data.last_from_tty = (self.tty_size_reader)();
            }
            new_size = data.current();
        }

        // Announce any updates.
        if new_size != prev_size {
            self.set_columns_lines_vars(new_size, parser);
        }
        new_size
    }

    fn set_columns_lines_vars(&self, val: Termsize, parser: &Parser) {
        let saved = self.setting_env_vars.swap(true, Ordering::Relaxed);
        parser.set_var_and_fire(L!("COLUMNS"), EnvMode::GLOBAL, vec![val.width.to_wstring()]);
        parser.set_var_and_fire(L!("LINES"), EnvMode::GLOBAL, vec![val.height.to_wstring()]);
        self.setting_env_vars.store(saved, Ordering::Relaxed);
    }

    /// Note that COLUMNS and/or LINES global variables changed.
    fn handle_columns_lines_var_change(&self, vars: &dyn Environment) {
        // Do nothing if we are the ones setting it.
        if self.setting_env_vars.load(Ordering::Relaxed) {
            return;
        }
        // Construct a new termsize from COLUMNS and LINES, then set it in our data.
        let new_termsize = Termsize {
            width: vars
                .getf(L!("COLUMNS"), EnvMode::GLOBAL)
                .map(|v| v.as_string())
                .and_then(|v| fish_wcstoi(&v).ok().map(|h| h as isize))
                .unwrap_or(Termsize::DEFAULT_WIDTH),
            height: vars
                .getf(L!("LINES"), EnvMode::GLOBAL)
                .map(|v| v.as_string())
                .and_then(|v| fish_wcstoi(&v).ok().map(|h| h as isize))
                .unwrap_or(Termsize::DEFAULT_HEIGHT),
        };

        // Store our termsize as an environment override.
        self.data
            .lock()
            .unwrap()
            .mark_override_from_env(new_termsize);
    }

    /// Note that a WINCH signal is received.
    /// Naturally this may be called from within a signal handler.
    pub fn handle_winch() {
        TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed);
    }

    pub fn invalidate_tty() {
        TTY_TERMSIZE_GEN_COUNT.fetch_add(1, Ordering::Relaxed);
    }
}

pub static SHARED_CONTAINER: TermsizeContainer = TermsizeContainer {
    data: Mutex::new(TermsizeData::defaults()),
    setting_env_vars: AtomicBool::new(false),
    tty_size_reader: read_termsize_from_tty,
};

const _: () = assert_sync::<TermsizeContainer>();

/// Helper to return the default termsize.
pub fn termsize_default() -> Termsize {
    Termsize::defaults()
}

/// Convenience helper to return the last known termsize.
pub fn termsize_last() -> Termsize {
    return SHARED_CONTAINER.last();
}

/// Called when the COLUMNS or LINES variables are changed.
pub fn handle_columns_lines_var_change(vars: &dyn Environment) {
    SHARED_CONTAINER.handle_columns_lines_var_change(vars);
}

pub fn termsize_update(parser: &Parser) -> Termsize {
    SHARED_CONTAINER.updating(parser)
}

pub fn termsize_invalidate_tty() {
    TermsizeContainer::invalidate_tty();
}

#[test]
#[serial]
fn test_termsize() {
    test_init();
    let env_global = EnvMode::GLOBAL;
    let parser = Parser::principal_parser();
    let vars = parser.vars();

    // Use a static variable so we can pretend we're the kernel exposing a terminal size.
    static STUBBY_TERMSIZE: Mutex<Option<Termsize>> = Mutex::new(None);
    fn stubby_termsize() -> Option<Termsize> {
        *STUBBY_TERMSIZE.lock().unwrap()
    }
    let ts = TermsizeContainer {
        data: Mutex::new(TermsizeData::defaults()),
        setting_env_vars: AtomicBool::new(false),
        tty_size_reader: stubby_termsize,
    };

    // Initially default value.
    assert_eq!(ts.last(), Termsize::defaults());

    // Haha we change the value, it doesn't even know.
    *STUBBY_TERMSIZE.lock().unwrap() = Some(Termsize {
        width: 42,
        height: 84,
    });
    assert_eq!(ts.last(), Termsize::defaults());

    // Ok let's tell it. But it still doesn't update right away.
    TermsizeContainer::handle_winch();
    assert_eq!(ts.last(), Termsize::defaults());

    // Ok now we tell it to update.
    ts.updating(parser);
    assert_eq!(ts.last(), Termsize::new(42, 84));
    assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "42");
    assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "84");

    // Wow someone set COLUMNS and LINES to a weird value.
    // Now the tty's termsize doesn't matter.
    vars.set_one(L!("COLUMNS"), env_global, L!("75").to_owned());
    vars.set_one(L!("LINES"), env_global, L!("150").to_owned());
    ts.handle_columns_lines_var_change(parser.vars());
    assert_eq!(ts.last(), Termsize::new(75, 150));
    assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "75");
    assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "150");

    vars.set_one(L!("COLUMNS"), env_global, L!("33").to_owned());
    ts.handle_columns_lines_var_change(parser.vars());
    assert_eq!(ts.last(), Termsize::new(33, 150));

    // Oh it got SIGWINCH, now the tty matters again.
    TermsizeContainer::handle_winch();
    assert_eq!(ts.last(), Termsize::new(33, 150));
    assert_eq!(ts.updating(parser), stubby_termsize().unwrap());
    assert_eq!(vars.get(L!("COLUMNS")).unwrap().as_string(), "42");
    assert_eq!(vars.get(L!("LINES")).unwrap().as_string(), "84");

    // Test initialize().
    vars.set_one(L!("COLUMNS"), env_global, L!("83").to_owned());
    vars.set_one(L!("LINES"), env_global, L!("38").to_owned());
    ts.initialize(vars);
    assert_eq!(ts.last(), Termsize::new(83, 38));

    // initialize() even beats the tty reader until a sigwinch.
    let ts2 = TermsizeContainer {
        data: Mutex::new(TermsizeData::defaults()),
        setting_env_vars: AtomicBool::new(false),
        tty_size_reader: stubby_termsize,
    };
    ts.initialize(parser.vars());
    ts2.updating(parser);
    assert_eq!(ts.last(), Termsize::new(83, 38));
    TermsizeContainer::handle_winch();
    assert_eq!(ts2.updating(parser), stubby_termsize().unwrap());
}