mod client;
mod config;

use anyhow::{Context, Result, bail};
use client::{ApiClient, MailError, MailItem};
use config::{Config, config_path, delete_config, load_config, save_config};
use crossterm::event::{Event, KeyCode, KeyEventKind, read};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use rpassword::prompt_password;
use serde_json::{Map, Value};
use std::env;
use std::io::{self, IsTerminal, Write};
use std::sync::{LazyLock, RwLock};

const BASE_URL: &str = "https://mail.hackclub.com";
const API_KEY_URL: &str = "https://mail.hackclub.com/my/api_keys";
const ITEMS_PER_PAGE: usize = 5;
const ANSI_RESET: &str = "\x1b[0m";
const APP_BANNER: &str = r#"                  _ _
                 (_) |
  _ __ ___   __ _ _| |
 | '_ ` _ \ / _` | | |
 | | | | | | (_| | | |
 |_| |_| |_|\__,_|_|_|
 "#;

static THEME: LazyLock<RwLock<Theme>> = LazyLock::new(|| RwLock::new(Theme::default()));

enum Action {
    Open(usize),
    Quit,
}

struct MenuState {
    action: Action,
    page: usize,
}

enum MenuCommand {
    Open(usize),
    NextPage,
    PreviousPage,
    StartSearch,
    SearchEdit(SearchEdit),
    Quit,
    Invalid(String),
}

enum SearchEdit {
    Append(char),
    Backspace,
    Replace(String),
    Finish,
}

#[derive(Copy, Clone)]
enum ThemeRole {
    Banner,
    Accent,
    Muted,
    Warning,
    Error,
    Success,
    PageInfo,
    Letter,
    Package,
    OtherMail,
    Source,
    Location,
    Border,
    Json,
    Link,
    Text,
    Field,
    StatusOther,
}

#[derive(Clone)]
struct Theme {
    banner: String,
    accent: String,
    muted: String,
    warning: String,
    error: String,
    success: String,
    page_info: String,
    letter: String,
    package: String,
    other_mail: String,
    source: String,
    location: String,
    border: String,
    json: String,
    link: String,
    text: String,
    field: String,
    status_other: String,
}

impl Default for Theme {
    fn default() -> Self {
        Self {
            banner: "1;96".to_string(),
            accent: "1;36".to_string(),
            muted: "2;37".to_string(),
            warning: "33".to_string(),
            error: "31".to_string(),
            success: "32".to_string(),
            page_info: "35".to_string(),
            letter: "1;96".to_string(),
            package: "33".to_string(),
            other_mail: "36".to_string(),
            source: "1;96".to_string(),
            location: "35".to_string(),
            border: "2;37".to_string(),
            json: "2;32".to_string(),
            link: "1;96".to_string(),
            text: "37".to_string(),
            field: "36".to_string(),
            status_other: "35".to_string(),
        }
    }
}

impl Theme {
    fn named(name: &str) -> Option<Self> {
        match name.trim().to_ascii_lowercase().as_str() {
            "" | "cyan" | "default" => Some(Self::default()),
            "red" => Some(Self {
                banner: "1;91".to_string(),
                accent: "1;31".to_string(),
                muted: "2;37".to_string(),
                warning: "1;93".to_string(),
                error: "1;91".to_string(),
                success: "1;92".to_string(),
                page_info: "1;95".to_string(),
                letter: "1;91".to_string(),
                package: "1;93".to_string(),
                other_mail: "91".to_string(),
                source: "1;91".to_string(),
                location: "1;95".to_string(),
                border: "2;37".to_string(),
                json: "2;91".to_string(),
                link: "1;91".to_string(),
                text: "37".to_string(),
                field: "1;31".to_string(),
                status_other: "1;95".to_string(),
            }),
            "green" => Some(Self {
                banner: "1;92".to_string(),
                accent: "1;32".to_string(),
                muted: "2;37".to_string(),
                warning: "1;93".to_string(),
                error: "1;91".to_string(),
                success: "1;92".to_string(),
                page_info: "1;96".to_string(),
                letter: "1;92".to_string(),
                package: "1;93".to_string(),
                other_mail: "92".to_string(),
                source: "1;92".to_string(),
                location: "1;96".to_string(),
                border: "2;37".to_string(),
                json: "2;92".to_string(),
                link: "1;92".to_string(),
                text: "37".to_string(),
                field: "1;32".to_string(),
                status_other: "1;96".to_string(),
            }),
            "blue" => Some(Self {
                banner: "1;94".to_string(),
                accent: "1;96".to_string(),
                muted: "2;37".to_string(),
                warning: "1;93".to_string(),
                error: "1;91".to_string(),
                success: "1;92".to_string(),
                page_info: "1;94".to_string(),
                letter: "1;96".to_string(),
                package: "1;93".to_string(),
                other_mail: "1;94".to_string(),
                source: "1;96".to_string(),
                location: "1;94".to_string(),
                border: "2;37".to_string(),
                json: "2;94".to_string(),
                link: "1;96".to_string(),
                text: "37".to_string(),
                field: "1;96".to_string(),
                status_other: "1;94".to_string(),
            }),
            "purple" => Some(Self {
                banner: "1;95".to_string(),
                accent: "1;35".to_string(),
                muted: "2;37".to_string(),
                warning: "1;93".to_string(),
                error: "1;91".to_string(),
                success: "1;92".to_string(),
                page_info: "1;95".to_string(),
                letter: "1;95".to_string(),
                package: "1;93".to_string(),
                other_mail: "35".to_string(),
                source: "1;95".to_string(),
                location: "1;96".to_string(),
                border: "2;37".to_string(),
                json: "2;95".to_string(),
                link: "1;95".to_string(),
                text: "37".to_string(),
                field: "1;35".to_string(),
                status_other: "1;96".to_string(),
            }),
            "yellow" | "gold" => Some(Self {
                banner: "1;93".to_string(),
                accent: "33".to_string(),
                muted: "2;37".to_string(),
                warning: "1;93".to_string(),
                error: "1;91".to_string(),
                success: "1;92".to_string(),
                page_info: "1;33".to_string(),
                letter: "1;93".to_string(),
                package: "33".to_string(),
                other_mail: "93".to_string(),
                source: "1;93".to_string(),
                location: "1;33".to_string(),
                border: "2;37".to_string(),
                json: "2;93".to_string(),
                link: "1;93".to_string(),
                text: "37".to_string(),
                field: "33".to_string(),
                status_other: "1;33".to_string(),
            }),
            _ => None,
        }
    }

    fn code(&self, role: ThemeRole) -> &str {
        match role {
            ThemeRole::Banner => &self.banner,
            ThemeRole::Accent => &self.accent,
            ThemeRole::Muted => &self.muted,
            ThemeRole::Warning => &self.warning,
            ThemeRole::Error => &self.error,
            ThemeRole::Success => &self.success,
            ThemeRole::PageInfo => &self.page_info,
            ThemeRole::Letter => &self.letter,
            ThemeRole::Package => &self.package,
            ThemeRole::OtherMail => &self.other_mail,
            ThemeRole::Source => &self.source,
            ThemeRole::Location => &self.location,
            ThemeRole::Border => &self.border,
            ThemeRole::Json => &self.json,
            ThemeRole::Link => &self.link,
            ThemeRole::Text => &self.text,
            ThemeRole::Field => &self.field,
            ThemeRole::StatusOther => &self.status_other,
        }
    }
}

fn main() {
    if let Err(error) = run() {
        eprintln!("Error: {error:#}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    sync_theme_from_config()?;

    if handle_cli_flags()? {
        return Ok(());
    }

    let client = ApiClient::new(BASE_URL)?;
    let mut api_key = load_or_prompt_api_key(&client)?;
    let mut current_page = 0;

    loop {
        let me = match client.fetch_me(&api_key) {
            Ok(me) => me,
            Err(MailError::InvalidAuth) => {
                eprintln!("The saved API key is no longer valid.");
                api_key = prompt_and_save_api_key(&client)?;
                continue;
            }
            Err(error) => return Err(error.into()),
        };

        let mail = match client.fetch_mail(&api_key) {
            Ok(mail) => mail,
            Err(MailError::InvalidAuth) => {
                eprintln!("The saved API key is no longer valid.");
                api_key = prompt_and_save_api_key(&client)?;
                continue;
            }
            Err(error) => return Err(error.into()),
        };

        current_page = clamp_page(current_page, mail.len());

        let menu_state = show_mail_menu(&me, &mail, current_page)?;
        current_page = menu_state.page;

        match menu_state.action {
            Action::Open(index) => {
                if let Some(item) = mail.get(index) {
                    show_detail_view(&client, &api_key, item)?;
                }
            }
            Action::Quit => break,
        }
    }

    Ok(())
}

fn sync_theme_from_config() -> Result<()> {
    let config = load_config()?;
    let mut theme = THEME
        .write()
        .expect("theme lock poisoned while applying config");
    *theme = config
        .as_ref()
        .and_then(|config| config.theme.as_deref())
        .and_then(Theme::named)
        .unwrap_or_default();
    Ok(())
}

fn handle_cli_flags() -> Result<bool> {
    let args = env::args().skip(1).collect::<Vec<_>>();

    if args.is_empty() {
        return Ok(false);
    }

    if args.len() == 1 && args[0] == "--logout" {
        let deleted = delete_config()?;

        if deleted {
            println!("{}", paint("Logged out of hc-mail.", "32"));
        } else {
            println!("{}", paint("No saved hc-mail session was found.", "33"));
        }

        return Ok(true);
    }

    if args.len() == 2 && args[0] == "--set-theme" {
        let theme_name = args[1].trim().to_ascii_lowercase();

        if Theme::named(&theme_name).is_none() {
            anyhow::bail!(
                "unknown theme `{}`. available themes: cyan, red, green, blue, purple, yellow",
                args[1]
            );
        }

        let mut config = load_config()?.unwrap_or(Config {
            api_key: String::new(),
            theme: None,
        });
        config.theme = Some(theme_name.clone());
        save_config(&config)?;
        sync_theme_from_config()?;
        println!(
            "{}",
            paint_role(
                &format!("Saved hc-mail theme: {theme_name}"),
                ThemeRole::Success
            )
        );

        return Ok(true);
    }

    anyhow::bail!("unknown argument: {}", args.join(" "));
}

fn load_or_prompt_api_key(client: &ApiClient) -> Result<String> {
    match load_config()? {
        Some(config) if !config.api_key.trim().is_empty() => {
            match client.validate_api_key(&config.api_key) {
                Ok(()) => Ok(config.api_key),
                Err(MailError::InvalidAuth) => {
                    eprintln!("The saved API key was rejected, so let's replace it.");
                    prompt_and_save_api_key(client)
                }
                Err(error) => Err(error.into()),
            }
        }
        _ => prompt_and_save_api_key(client),
    }
}

fn prompt_and_save_api_key(client: &ApiClient) -> Result<String> {
    let path = config_path()?;

    println!(
        "{}",
        paint(
            "Hack Club Mail needs an API key before it can load your mail.",
            "1;36"
        )
    );
    println!(
        "{} {}",
        paint_role("Create one at:", ThemeRole::Warning),
        paint_role(API_KEY_URL, ThemeRole::Link)
    );
    println!(
        "{} {}",
        paint_role("It will be stored at:", ThemeRole::Warning),
        paint_role(&path.display().to_string(), ThemeRole::Muted)
    );
    println!();

    loop {
        let api_key = prompt_password("Hack Club Mail API key: ")
            .context("failed to read API key from the terminal")?;
        let api_key = api_key.trim().to_string();

        if api_key.is_empty() {
            println!(
                "{}\n",
                paint_role("The API key cannot be empty.", ThemeRole::Error)
            );
            continue;
        }

        match client.validate_api_key(&api_key) {
            Ok(()) => {
                let mut config = load_config()?.unwrap_or(Config {
                    api_key: String::new(),
                    theme: None,
                });
                config.api_key = api_key.clone();
                save_config(&config)?;
                println!("{}\n", paint_role("API key saved.", ThemeRole::Success));
                return Ok(api_key);
            }
            Err(MailError::InvalidAuth) => {
                println!(
                    "{}\n",
                    paint_role(
                        "That API key was rejected. Please try again.",
                        ThemeRole::Error
                    )
                );
            }
            Err(error) => return Err(error.into()),
        }
    }
}

fn show_mail_menu(me: &Value, mail: &[MailItem], initial_page: usize) -> Result<MenuState> {
    let mut page = clamp_page(initial_page, mail.len());
    let mut notice: Option<String> = None;
    let mut search_query = String::new();
    let mut search_mode = false;

    loop {
        let filtered_indices = filtered_mail_indices(mail, Some(&search_query));
        page = clamp_page(page, filtered_indices.len());

        clear_screen()?;

        let total_pages = total_pages(filtered_indices.len());
        let (start, end) = page_bounds(filtered_indices.len(), page);

        println!("{}", paint_role(APP_BANNER, ThemeRole::Banner));
        if !filtered_indices.is_empty() {
            println!(
                "{}",
                paint_role(
                    &format!(
                        "Page {} of {} · showing items {}-{} of {}",
                        page + 1,
                        total_pages,
                        start + 1,
                        end,
                        filtered_indices.len()
                    ),
                    ThemeRole::PageInfo
                )
            );
        }
        let account = describe_me(me);
        if !account.is_empty() {
            println!("{}", paint_role(&account, ThemeRole::Muted));
        }
        if search_mode || !search_query.is_empty() {
            let cursor = if search_mode { "_" } else { "" };
            println!(
                "{}",
                paint_role(
                    &format!(
                        "Search: {search_query}{cursor} ({})",
                        filtered_indices.len()
                    ),
                    ThemeRole::Warning
                )
            );
        }
        println!();

        if let Some(message) = &notice {
            println!("{}", paint_role(message, ThemeRole::Error));
            println!();
        }

        if mail.is_empty() {
            println!("{}", paint_role("No mail found yet.", ThemeRole::Warning));
            println!(
                "{}",
                paint_role("Press `s` to search or `q` to quit.", ThemeRole::Muted)
            );
        } else if filtered_indices.is_empty() {
            println!(
                "{}",
                paint_role("No search results found.", ThemeRole::Warning)
            );
            println!(
                "{}",
                paint_role(
                    "Type to keep searching. Press `Esc` or `Enter` to close search, or `q` to quit.",
                    ThemeRole::Muted
                )
            );
        } else {
            println!("{}", build_mail_table(mail, &filtered_indices, page));
            println!();
            println!(
                "{}",
                paint_role(
                    if search_mode {
                        "Type to search live. Use Backspace to edit, `Esc` or `Enter` to close search, or the arrow keys to change pages."
                            .to_string()
                    } else {
                        format!(
                            "Press 1-{} to open mail, `s` to search, use the left/right arrow keys for previous or next page, or `q` to quit.",
                            end - start
                        )
                    }
                    .as_str(),
                    ThemeRole::Muted
                )
            );
        }

        if mail.is_empty() || filtered_indices.is_empty() {
            notice = match read_menu_command(0, search_mode)? {
                MenuCommand::StartSearch => {
                    search_mode = true;
                    None
                }
                MenuCommand::SearchEdit(edit) => {
                    apply_search_edit(&mut search_query, &mut search_mode, edit);
                    page = 0;
                    None
                }
                MenuCommand::Quit => {
                    return Ok(MenuState {
                        action: Action::Quit,
                        page,
                    });
                }
                MenuCommand::NextPage | MenuCommand::PreviousPage => None,
                MenuCommand::Open(_) => None,
                MenuCommand::Invalid(message) => Some(message),
            };
            continue;
        }

        match read_menu_command(end - start, search_mode)? {
            MenuCommand::StartSearch => {
                search_mode = true;
                notice = None;
            }
            MenuCommand::SearchEdit(edit) => {
                apply_search_edit(&mut search_query, &mut search_mode, edit);
                page = 0;
                notice = None;
            }
            MenuCommand::Quit => {
                return Ok(MenuState {
                    action: Action::Quit,
                    page,
                });
            }
            MenuCommand::NextPage => {
                if page + 1 < total_pages {
                    page += 1;
                    notice = None;
                }
            }
            MenuCommand::PreviousPage => {
                if page > 0 {
                    page -= 1;
                    notice = None;
                }
            }
            MenuCommand::Open(index_on_page) => {
                return Ok(MenuState {
                    action: Action::Open(filtered_indices[start + index_on_page]),
                    page,
                });
            }
            MenuCommand::Invalid(message) => notice = Some(message),
        }
    }
}

fn show_detail_view(client: &ApiClient, api_key: &str, item: &MailItem) -> Result<()> {
    let detail = match client.fetch_detail(api_key, item) {
        Ok(detail) => detail,
        Err(MailError::InvalidAuth) => {
            bail!("your API key became invalid while loading mail details");
        }
        Err(error) => return Err(error.into()),
    };

    clear_screen()?;
    println!("{}", render_detail(item, &detail));
    pause(&paint_role(
        "Press Enter to go back to your mail list...",
        ThemeRole::Muted,
    ))?;

    Ok(())
}

fn build_mail_table(mail: &[MailItem], filtered_indices: &[usize], page: usize) -> String {
    let show_status = filtered_indices
        .iter()
        .any(|&index| mail[index].status.is_some());
    let show_time = filtered_indices
        .iter()
        .any(|&index| mail[index].timestamp.is_some());
    let mut rows = Vec::new();
    let (start, end) = page_bounds(filtered_indices.len(), page);

    for (index, mail_index) in filtered_indices[start..end].iter().enumerate() {
        let item = &mail[*mail_index];
        let mut row = vec![
            paint_role(&(index + 1).to_string(), ThemeRole::Accent),
            style_mail_type(&item.item_type.label()),
            paint(&item.title, "1"),
        ];

        if show_status {
            row.push(item.status.as_deref().map(style_status).unwrap_or_default());
        }

        if show_time {
            row.push(
                item.timestamp
                    .as_deref()
                    .map(short_timestamp)
                    .map(|timestamp| paint_role(timestamp, ThemeRole::Muted))
                    .unwrap_or_default(),
            );
        }

        rows.push(row);
    }

    let headers = build_mail_header(show_status, show_time);
    format_table(&headers, &rows)
}

fn build_mail_header(show_status: bool, show_time: bool) -> Vec<String> {
    let mut header = vec![
        paint_role("#", ThemeRole::Accent),
        paint_role("Type", ThemeRole::Accent),
        paint_role("Title", ThemeRole::Accent),
    ];

    if show_status {
        header.push(paint_role("Status", ThemeRole::Accent));
    }

    if show_time {
        header.push(paint_role("Time", ThemeRole::Accent));
    }

    header
}

fn render_detail(item: &MailItem, payload: &Value) -> String {
    let detail = unwrap_detail_root(item, payload);
    let mut sections = Vec::new();

    sections.push(format!(
        "{}: {}",
        style_mail_type(&item.item_type.label()),
        paint(&item.title, "1")
    ));

    if let Some(summary) = render_summary_table(detail) {
        sections.push(summary);
    }

    if let Some(events) = render_event_sections(detail) {
        sections.push(events);
    }

    if let Some(extra) = render_extra_json(detail) {
        sections.push(extra);
    }

    sections.join("\n\n")
}

fn unwrap_detail_root<'a>(item: &MailItem, payload: &'a Value) -> &'a Value {
    if let Some(key) = item.item_type.detail_root_key() {
        if let Some(value) = payload.get(key) {
            return value;
        }
    }

    if let Some(object) = payload.as_object() {
        if object.len() == 1 {
            if let Some(value) = object.values().next() {
                return value;
            }
        }
    }

    payload
}

fn render_summary_table(detail: &Value) -> Option<String> {
    let object = detail.as_object()?;
    let mut rows = Vec::new();

    for key in sorted_summary_keys(object) {
        let value = object.get(key.as_str())?;

        if is_event_field(key.as_str()) || value.is_object() {
            continue;
        }

        if let Some(rendered) = render_inline_value(value) {
            rows.push((humanize_key(key.as_str()), rendered));
        }
    }

    if rows.is_empty() {
        return None;
    }

    let table_rows = rows
        .into_iter()
        .map(|(field, value)| {
            vec![
                paint_role(&field, ThemeRole::Field),
                style_detail_value(&value),
            ]
        })
        .collect::<Vec<_>>();

    Some(format!(
        "{}\n{}",
        paint_role("Summary", ThemeRole::Accent),
        format_table(
            &[
                paint_role("Field", ThemeRole::Accent),
                paint_role("Value", ThemeRole::Accent),
            ],
            &table_rows
        )
    ))
}

fn render_event_sections(detail: &Value) -> Option<String> {
    let object = detail.as_object()?;
    let mut sections = Vec::new();

    for key in ["events", "scans", "history", "updates"] {
        let array = match object.get(key).and_then(Value::as_array) {
            Some(array) if !array.is_empty() => array,
            _ => continue,
        };

        if let Some(table) = render_object_array_table(key, array) {
            sections.push(table);
        }
    }

    if sections.is_empty() {
        None
    } else {
        Some(sections.join("\n\n"))
    }
}

fn render_object_array_table(label: &str, array: &[Value]) -> Option<String> {
    if !array.iter().all(Value::is_object) {
        return serde_json::to_string_pretty(&Value::Array(array.to_vec()))
            .ok()
            .map(|json| {
                format!(
                    "{}\n{}",
                    paint_role(&humanize_key(label), ThemeRole::Accent),
                    json
                )
            });
    }

    let preferred_columns = [
        "happened_at",
        "created_at",
        "updated_at",
        "time",
        "date",
        "description",
        "message",
        "location",
        "source",
        "status",
    ];

    let mut columns = preferred_columns
        .iter()
        .filter(|column| array.iter().any(|entry| entry.get(**column).is_some()))
        .map(|column| (*column).to_string())
        .collect::<Vec<_>>();

    if columns.is_empty() {
        columns = collect_object_keys(array);
    }

    if columns.is_empty() {
        return None;
    }

    let headers = columns
        .iter()
        .map(|column| paint_role(&humanize_key(column), ThemeRole::Accent))
        .collect::<Vec<_>>();
    let mut rows = Vec::new();

    for entry in array {
        let mut row = Vec::new();

        for column in &columns {
            let text = entry
                .get(column)
                .and_then(render_inline_value)
                .unwrap_or_default();
            let text = if column.ends_with("_at") || column == "time" || column == "date" {
                paint_role(short_timestamp(&text), ThemeRole::Muted)
            } else if column == "status" {
                style_status(&text)
            } else if column == "source" {
                paint_role(&text, ThemeRole::Source)
            } else if column == "location" {
                paint_role(&text, ThemeRole::Location)
            } else {
                style_detail_value(&text)
            };
            row.push(text);
        }

        rows.push(row);
    }

    Some(format!(
        "{}\n{}",
        paint_role(&humanize_key(label), ThemeRole::Accent),
        format_table(&headers, &rows)
    ))
}

fn render_extra_json(detail: &Value) -> Option<String> {
    let object = detail.as_object()?;
    let mut extra = Map::new();

    for (key, value) in object {
        if is_event_field(key) {
            continue;
        }

        match value {
            Value::Object(_) => {
                extra.insert(key.clone(), value.clone());
            }
            Value::Array(items) if items.iter().any(Value::is_object) => {
                extra.insert(key.clone(), value.clone());
            }
            _ => {}
        }
    }

    if extra.is_empty() {
        return None;
    }

    serde_json::to_string_pretty(&Value::Object(extra))
        .ok()
        .map(|json| {
            format!(
                "{}\n{}",
                paint_role("Extra JSON", ThemeRole::Accent),
                paint_role(&json, ThemeRole::Json)
            )
        })
}

fn collect_object_keys(array: &[Value]) -> Vec<String> {
    let mut keys = Vec::new();

    for entry in array {
        if let Some(object) = entry.as_object() {
            for key in object.keys() {
                if !keys.contains(key) {
                    keys.push(key.clone());
                }
            }
        }
    }

    keys
}

fn sorted_summary_keys(object: &Map<String, Value>) -> Vec<String> {
    let mut keys = object.keys().cloned().collect::<Vec<_>>();
    let preferred = [
        "id",
        "title",
        "status",
        "type",
        "created_at",
        "updated_at",
        "received_at",
        "sent_at",
        "tracking_number",
        "tags",
    ];

    keys.sort_by(|left, right| {
        let left_index = preferred
            .iter()
            .position(|preferred_key| preferred_key == left);
        let right_index = preferred
            .iter()
            .position(|preferred_key| preferred_key == right);

        match (left_index, right_index) {
            (Some(left_index), Some(right_index)) => left_index.cmp(&right_index),
            (Some(_), None) => std::cmp::Ordering::Less,
            (None, Some(_)) => std::cmp::Ordering::Greater,
            (None, None) => left.cmp(right),
        }
    });

    keys
}

fn render_inline_value(value: &Value) -> Option<String> {
    match value {
        Value::Null => None,
        Value::String(text) => {
            let trimmed = text.trim();
            if trimmed.is_empty() {
                None
            } else {
                Some(trimmed.to_string())
            }
        }
        Value::Bool(boolean) => Some(boolean.to_string()),
        Value::Number(number) => Some(number.to_string()),
        Value::Array(items) if items.iter().all(is_scalar_value) => {
            let rendered = items
                .iter()
                .filter_map(render_inline_value)
                .collect::<Vec<_>>();

            if rendered.is_empty() {
                None
            } else {
                Some(rendered.join(", "))
            }
        }
        _ => None,
    }
}

fn is_scalar_value(value: &Value) -> bool {
    matches!(
        value,
        Value::Null | Value::String(_) | Value::Bool(_) | Value::Number(_)
    )
}

fn is_event_field(key: &str) -> bool {
    matches!(key, "events" | "scans" | "history" | "updates")
}

fn describe_me(payload: &Value) -> String {
    let root = payload
        .get("me")
        .or_else(|| payload.get("user"))
        .or_else(|| payload.get("account"))
        .unwrap_or(payload);

    let name = lookup_string(root, &["name", "display_name", "username", "slug"]);
    let email = lookup_string(root, &["email"]);

    match (name, email) {
        (Some(name), Some(email)) => format!("Signed in as {name} <{email}>"),
        (Some(name), None) => format!("Signed in as {name}"),
        (None, Some(email)) => format!("Signed in as {email}"),
        (None, None) => String::new(),
    }
}

fn lookup_string(value: &Value, keys: &[&str]) -> Option<String> {
    let object = value.as_object()?;

    for key in keys {
        if let Some(entry) = object.get(*key) {
            match entry {
                Value::String(text) => {
                    let trimmed = text.trim();
                    if !trimmed.is_empty() {
                        return Some(trimmed.to_string());
                    }
                }
                Value::Number(number) => return Some(number.to_string()),
                Value::Bool(boolean) => return Some(boolean.to_string()),
                _ => {}
            }
        }
    }

    None
}

fn humanize_key(key: &str) -> String {
    key.split('_')
        .filter(|part| !part.is_empty())
        .map(|part| {
            let mut chars = part.chars();
            match chars.next() {
                Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
                None => String::new(),
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

fn short_timestamp(value: &str) -> &str {
    value.split('T').next().unwrap_or(value)
}

fn total_pages(item_count: usize) -> usize {
    if item_count == 0 {
        1
    } else {
        item_count.div_ceil(ITEMS_PER_PAGE)
    }
}

fn clamp_page(page: usize, item_count: usize) -> usize {
    page.min(total_pages(item_count).saturating_sub(1))
}

fn page_bounds(item_count: usize, page: usize) -> (usize, usize) {
    if item_count == 0 {
        return (0, 0);
    }

    let page = clamp_page(page, item_count);
    let start = page * ITEMS_PER_PAGE;
    let end = (start + ITEMS_PER_PAGE).min(item_count);
    (start, end)
}

fn format_table(headers: &[String], rows: &[Vec<String>]) -> String {
    let column_count = headers.len();
    let mut widths = headers
        .iter()
        .map(|header| display_width(header))
        .collect::<Vec<_>>();

    for row in rows {
        for (index, cell) in row.iter().enumerate().take(column_count) {
            widths[index] = widths[index].max(display_width(cell));
        }
    }

    let separator = paint(
        &format!(
            "+-{}-+",
            widths
                .iter()
                .map(|width| "-".repeat(*width))
                .collect::<Vec<_>>()
                .join("-+-")
        ),
        &theme_code(ThemeRole::Border),
    );

    let mut lines = Vec::new();
    lines.push(separator.clone());
    lines.push(format_table_row(headers, &widths));
    lines.push(separator.clone());

    for row in rows {
        lines.push(format_table_row(row, &widths));
    }

    lines.push(separator);
    lines.join("\n")
}

fn format_table_row(cells: &[String], widths: &[usize]) -> String {
    let rendered = cells
        .iter()
        .enumerate()
        .map(|(index, cell)| {
            let sanitized = sanitize_table_cell(cell);
            pad_visible(&sanitized, widths[index])
        })
        .collect::<Vec<_>>()
        .join(" | ");

    format!(
        "{} {} {}",
        paint_role("|", ThemeRole::Border),
        rendered,
        paint_role("|", ThemeRole::Border)
    )
}

fn sanitize_table_cell(value: &str) -> String {
    value.replace('\n', " ")
}

fn display_width(value: &str) -> usize {
    strip_ansi(&sanitize_table_cell(value)).chars().count()
}

fn pad_visible(value: &str, width: usize) -> String {
    let visible_width = strip_ansi(value).chars().count();
    let padding = width.saturating_sub(visible_width);
    format!("{value}{}", " ".repeat(padding))
}

fn clear_screen() -> Result<()> {
    if io::stdout().is_terminal() {
        print!("\x1B[2J\x1B[H");
        io::stdout().flush().context("failed to flush stdout")?;
    }

    Ok(())
}

fn pause(message: &str) -> Result<()> {
    let _ = read_line(&format!("{message}\n"))?;
    Ok(())
}

fn read_line(prompt: &str) -> Result<String> {
    print!("{prompt}");
    io::stdout().flush().context("failed to flush stdout")?;

    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .context("failed to read from stdin")?;

    Ok(input.trim().to_string())
}

fn paint(value: &str, code: &str) -> String {
    if io::stdout().is_terminal() {
        format!("\x1b[{code}m{value}{ANSI_RESET}")
    } else {
        value.to_string()
    }
}

fn paint_role(value: &str, role: ThemeRole) -> String {
    let code = theme_code(role);
    paint(value, &code)
}

fn theme_code(role: ThemeRole) -> String {
    let theme = THEME.read().expect("theme lock poisoned while reading");
    theme.code(role).to_string()
}

fn style_mail_type(value: &str) -> String {
    match value.to_ascii_lowercase().as_str() {
        "letter" => paint_role(value, ThemeRole::Letter),
        "package" => paint_role(value, ThemeRole::Package),
        _ => paint_role(value, ThemeRole::OtherMail),
    }
}

fn style_status(value: &str) -> String {
    let lower = value.to_ascii_lowercase();

    if lower.contains("deliver") || lower.contains("complete") || lower.contains("received") {
        paint_role(value, ThemeRole::Success)
    } else if lower.contains("transit") || lower.contains("ship") || lower.contains("process") {
        paint_role(value, ThemeRole::Warning)
    } else if lower.contains("return") || lower.contains("fail") || lower.contains("error") {
        paint_role(value, ThemeRole::Error)
    } else {
        paint_role(value, ThemeRole::StatusOther)
    }
}

fn style_detail_value(value: &str) -> String {
    if value.is_empty() {
        String::new()
    } else if value.starts_with("http://") || value.starts_with("https://") {
        paint_role(value, ThemeRole::Link)
    } else if value.chars().all(|ch| ch.is_ascii_digit() || ch == '-') && value.contains('-') {
        paint_role(value, ThemeRole::Muted)
    } else {
        paint_role(value, ThemeRole::Text)
    }
}

fn strip_ansi(value: &str) -> String {
    let mut result = String::new();
    let mut chars = value.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch == '\x1b' && chars.peek() == Some(&'[') {
            chars.next();

            for next in chars.by_ref() {
                if next.is_ascii_alphabetic() {
                    break;
                }
            }
        } else {
            result.push(ch);
        }
    }

    result
}

fn read_menu_command(visible_items: usize, search_mode: bool) -> Result<MenuCommand> {
    if io::stdin().is_terminal() && io::stdout().is_terminal() {
        return read_menu_command_raw(visible_items, search_mode);
    }

    if search_mode {
        let input = read_line("Search: ")?;
        return Ok(MenuCommand::SearchEdit(SearchEdit::Replace(input)));
    }

    let input = read_line("")?;
    Ok(parse_menu_command(&input, visible_items))
}

fn read_menu_command_raw(visible_items: usize, search_mode: bool) -> Result<MenuCommand> {
    let _raw_mode = RawModeGuard::new()?;

    loop {
        let event = read().context("failed to read terminal input")?;

        let Event::Key(key) = event else {
            continue;
        };

        if key.kind != KeyEventKind::Press {
            continue;
        }

        let command = match key.code {
            KeyCode::Char(character) if search_mode => MenuCommand::SearchEdit(SearchEdit::Append(character)),
            KeyCode::Backspace if search_mode => MenuCommand::SearchEdit(SearchEdit::Backspace),
            KeyCode::Esc | KeyCode::Enter if search_mode => MenuCommand::SearchEdit(SearchEdit::Finish),
            KeyCode::Char(character) => {
                let lower = character.to_ascii_lowercase();

                if lower == 'q' {
                    MenuCommand::Quit
                } else if lower == 's' {
                    MenuCommand::StartSearch
                } else if let Some(number) = character.to_digit(10) {
                    parse_menu_number(number as usize, visible_items)
                } else {
                    MenuCommand::Invalid(
                        "Pick a valid number on this page, press `s` to search, use the left/right arrow keys, or `q`."
                            .to_string(),
                    )
                }
            }
            KeyCode::Right => MenuCommand::NextPage,
            KeyCode::Left => MenuCommand::PreviousPage,
            KeyCode::Esc => MenuCommand::Quit,
            _ => MenuCommand::Invalid(
                "Pick a valid number on this page, press `s` to search, use the left/right arrow keys, or `q`."
                    .to_string(),
            ),
        };

        return Ok(command);
    }
}

fn parse_menu_command(input: &str, visible_items: usize) -> MenuCommand {
    if input.eq_ignore_ascii_case("q") || input.eq_ignore_ascii_case("quit") {
        return MenuCommand::Quit;
    }

    if input.eq_ignore_ascii_case("s") || input.eq_ignore_ascii_case("search") {
        return MenuCommand::StartSearch;
    }

    if input == "\u{1b}[C" || input.eq_ignore_ascii_case("right") {
        return MenuCommand::NextPage;
    }

    if input == "\u{1b}[D" || input.eq_ignore_ascii_case("left") {
        return MenuCommand::PreviousPage;
    }

    match input.parse::<usize>() {
        Ok(number) => parse_menu_number(number, visible_items),
        Err(_) => MenuCommand::Invalid(
            "Pick a valid number on this page, press `s` to search, use the left/right arrow keys, or `q`."
                .to_string(),
        ),
    }
}

fn parse_menu_number(number: usize, visible_items: usize) -> MenuCommand {
    if number > 0 && number <= visible_items {
        MenuCommand::Open(number - 1)
    } else {
        MenuCommand::Invalid(
            "Pick a valid number on this page, press `s` to search, use the left/right arrow keys, or `q`."
                .to_string(),
        )
    }
}

fn filtered_mail_indices(mail: &[MailItem], query: Option<&str>) -> Vec<usize> {
    let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
        return (0..mail.len()).collect();
    };

    let query = query.to_ascii_lowercase();

    mail.iter()
        .enumerate()
        .filter(|(_, item)| item_matches_query(item, &query))
        .map(|(index, _)| index)
        .collect()
}

fn item_matches_query(item: &MailItem, query: &str) -> bool {
    let query = query.to_ascii_lowercase();

    item.id.to_ascii_lowercase().contains(&query)
        || item.title.to_ascii_lowercase().contains(&query)
        || item.item_type.label().to_ascii_lowercase().contains(&query)
        || item
            .status
            .as_deref()
            .map(|status| status.to_ascii_lowercase().contains(&query))
            .unwrap_or(false)
        || item
            .timestamp
            .as_deref()
            .map(|timestamp| timestamp.to_ascii_lowercase().contains(&query))
            .unwrap_or(false)
}

fn apply_search_edit(search_query: &mut String, search_mode: &mut bool, edit: SearchEdit) {
    match edit {
        SearchEdit::Append(character) => search_query.push(character),
        SearchEdit::Backspace => {
            search_query.pop();
        }
        SearchEdit::Replace(value) => {
            *search_query = value;
            *search_mode = false;
        }
        SearchEdit::Finish => {
            *search_mode = false;
        }
    }
}

struct RawModeGuard;

impl RawModeGuard {
    fn new() -> Result<Self> {
        enable_raw_mode().context("failed to enable raw terminal mode")?;
        Ok(Self)
    }
}

impl Drop for RawModeGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
    }
}

#[cfg(test)]
mod tests {
    use super::{
        ITEMS_PER_PAGE, MenuCommand, clamp_page, filtered_mail_indices, item_matches_query,
        page_bounds, parse_menu_command, total_pages,
    };
    use crate::client::{MailItem, MailType};

    #[test]
    fn paginates_in_groups_of_five() {
        assert_eq!(ITEMS_PER_PAGE, 5);
        assert_eq!(total_pages(0), 1);
        assert_eq!(total_pages(1), 1);
        assert_eq!(total_pages(5), 1);
        assert_eq!(total_pages(6), 2);
        assert_eq!(total_pages(11), 3);
    }

    #[test]
    fn computes_page_bounds_for_last_partial_page() {
        assert_eq!(page_bounds(12, 0), (0, 5));
        assert_eq!(page_bounds(12, 1), (5, 10));
        assert_eq!(page_bounds(12, 2), (10, 12));
    }

    #[test]
    fn clamps_pages_that_are_too_large() {
        assert_eq!(clamp_page(0, 0), 0);
        assert_eq!(clamp_page(3, 12), 2);
    }

    #[test]
    fn parses_arrow_navigation_commands() {
        assert!(matches!(
            parse_menu_command("\u{1b}[D", 5),
            MenuCommand::PreviousPage
        ));
        assert!(matches!(
            parse_menu_command("\u{1b}[C", 5),
            MenuCommand::NextPage
        ));
        assert!(matches!(parse_menu_command("3", 5), MenuCommand::Open(2)));
    }

    #[test]
    fn filters_against_the_whole_catalog() {
        let mail = vec![
            MailItem {
                id: "abc123".to_string(),
                item_type: MailType::Letter,
                title: "Postcard from HQ".to_string(),
                status: Some("delivered".to_string()),
                timestamp: Some("2026-03-01".to_string()),
            },
            MailItem {
                id: "pkg789".to_string(),
                item_type: MailType::Package,
                title: "Sticker pack".to_string(),
                status: Some("in_transit".to_string()),
                timestamp: Some("2026-03-02".to_string()),
            },
        ];

        assert_eq!(filtered_mail_indices(&mail, Some("sticker")), vec![1]);
        assert_eq!(filtered_mail_indices(&mail, Some("abc123")), vec![0]);
        assert_eq!(filtered_mail_indices(&mail, Some("letter")), vec![0]);
        assert_eq!(filtered_mail_indices(&mail, Some("delivered")), vec![0]);
    }

    #[test]
    fn item_matching_is_case_insensitive() {
        let item = MailItem {
            id: "pkg789".to_string(),
            item_type: MailType::Package,
            title: "Sticker Pack".to_string(),
            status: Some("In_Transit".to_string()),
            timestamp: Some("2026-03-02".to_string()),
        };

        assert!(item_matches_query(&item, "sticker"));
        assert!(item_matches_query(&item, "PACKAGE"));
        assert!(item_matches_query(&item, "in_transit"));
    }
}