use std::collections::HashSet;
use std::env;
use std::fs;
use std::io;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

const VERSION: &str = env!("CARGO_PKG_VERSION");

const HELP: &str = "\
bashistory — tidy ~/.bash_history by removing noise and duplicates.

Usage:  bashistory [OPTIONS]

Options:
  -h, --help        Show this message
  -V, --version     Show version
  -n, --dry-run     Show what would be removed (no files changed)
  -s, --stats       Print removal counts after running

Environment:
  BASHISTORY_EXTRA_NOISE   Space-separated extra noise words
  BASHISTORY_MAX_AGE_DAYS  Max age in days for typo removal (default: 14)
  HISTFILE                 Custom bash history path

Config:  ~/.config/bashistory/noise.txt  (one per line, # for comments)";

const NOISE: &[&str] = &[
    "ls", "ll", "la", "l", "sl", "lt", "ltr",
    "cd",
    "pwd", "clear", "cl", "cls", "reset", "tput",
    "exit", "quit", "logout", "logoff",
    "history", "fc", "fg", "bg", "jobs", "disown", "suspend",
    "help", "which", "type", "whatis", "whereis", "apropos",
    "pushd", "popd", "dirs",
    "echo", "date", "whoami", "hostname", "uname",
    "shutdown", "reboot", "poweroff", "init",
    "sudo !!",
];

#[derive(Debug)]
struct Args {
    dry_run: bool,
    stats: bool,
}

#[derive(Clone, Debug)]
struct Entry<'a> {
    cmd: &'a str,
    cmd_line: &'a str,
    timestamp_line: Option<&'a str>,
    seconds: Option<u64>,
}

#[derive(Default)]
struct Stats {
    noise: usize,
    duplicates: usize,
    old_typos: usize,
    empty: usize,
}

fn main() {
    let args = match parse_args() {
        Some(a) => a,
        None => return,
    };

    let path = history_path();

    if !args.dry_run && !file_changed(&path) {
        if args.stats {
            println!("bashistory: nothing to clean");
        }
        return;
    }

    let content = match fs::read_to_string(&path) {
        Ok(c) => c,
        Err(e) => {
            if e.kind() != io::ErrorKind::NotFound {
                eprintln!("bashistory: failed to read {}: {}", path.display(), e);
            }
            return;
        }
    };

    if content.is_empty() {
        if !args.dry_run {
            save_state(&path);
        }
        return;
    }

    let lines: Vec<&str> = content.lines().collect();
    let has_ts = detect_timestamped(&lines);
    let entries = parse_entries(&lines, has_ts);
    let noise = noise_set();
    let cutoff = age_cutoff();

    let mut stats = Stats::default();
    let mut seen: HashSet<&str> = HashSet::new();
    let mut kept: Vec<Entry> = Vec::new();

    for entry in entries.iter().rev() {
        let trimmed = entry.cmd.trim();
        if trimmed.is_empty() {
            stats.empty += 1;
            continue;
        }
        if is_noise(trimmed, &noise) {
            stats.noise += 1;
            continue;
        }
        if is_wrong(trimmed) {
            if let Some(secs) = entry.seconds {
                if secs < cutoff {
                    stats.old_typos += 1;
                    continue;
                }
            }
        }
        if seen.insert(trimmed) {
            kept.push(entry.clone());
        } else {
            stats.duplicates += 1;
        }
    }

    kept.reverse();

    let total_removed = stats.noise + stats.duplicates + stats.old_typos + stats.empty;

    if args.dry_run {
        print_dry_run(&stats, kept.len(), path.display().to_string());
        return;
    }

    if total_removed == 0 {
        if args.stats {
            println!("bashistory: nothing to clean");
        }
        save_state(&path);
        return;
    }

    let bak = PathBuf::from(format!("{}.bak", path.display()));
    if fs::copy(&path, &bak).is_err() {
        eprintln!("bashistory: failed to create backup {}", bak.display());
    }

    let mut out = String::new();
    for e in &kept {
        if let Some(ts) = e.timestamp_line {
            out.push_str(ts);
            out.push('\n');
        }
        out.push_str(e.cmd_line);
        out.push('\n');
    }

    let tmp = PathBuf::from(format!("{}.tmp", path.display()));
    if let Err(e) = fs::write(&tmp, &out) {
        eprintln!("bashistory: failed to write {}: {}", tmp.display(), e);
        return;
    }
    if let Err(e) = fs::rename(&tmp, &path) {
        eprintln!("bashistory: failed to rename {} → {}: {}", tmp.display(), path.display(), e);
        return;
    }

    save_state(&path);

    if args.stats {
        print_stats(&stats, path.display().to_string());
    } else {
        eprintln!(
            "bashistory: cleaned {}: {} noise, {} dups, {} typos, {} empty → {} kept",
            path.display(),
            stats.noise,
            stats.duplicates,
            stats.old_typos,
            stats.empty,
            kept.len()
        );
    }
}

fn parse_args() -> Option<Args> {
    let mut dry_run = false;
    let mut stats = false;
    for arg in env::args().skip(1) {
        match arg.as_str() {
            "-h" | "--help" => {
                print!("{}", HELP);
                return None;
            }
            "-V" | "--version" => {
                println!("bashistory {}", VERSION);
                return None;
            }
            "-n" | "--dry-run" => dry_run = true,
            "-s" | "--stats" => stats = true,
            other => {
                eprintln!("bashistory: unknown option '{}'", other);
                eprintln!("Try 'bashistory --help'.");
                return None;
            }
        }
    }
    Some(Args { dry_run, stats })
}

// ── parsing ──

fn detect_timestamped(lines: &[&str]) -> bool {
    let mut hash = 0u32;
    let mut valid = 0u32;
    for line in lines.iter().take(50) {
        if let Some(stripped) = line.strip_prefix('#') {
            hash += 1;
            if stripped.trim().parse::<u64>().is_ok_and(|t| t >= 900_000_000) {
                valid += 1;
            }
        }
    }
    hash > 0 && valid * 2 >= hash
}

fn parse_entries<'a>(lines: &'a [&'a str], has_ts: bool) -> Vec<Entry<'a>> {
    let mut entries = Vec::new();
    if !has_ts {
        for line in lines {
            entries.push(Entry {
                cmd: line.trim(),
                cmd_line: line,
                timestamp_line: None,
                seconds: None,
            });
        }
        return entries;
    }

    let mut i = 0;
    while i < lines.len() {
        if lines[i].starts_with('#') {
            let seconds = parse_timestamp(lines[i]).ok();
            if i + 1 < lines.len() && !lines[i + 1].starts_with('#') {
                entries.push(Entry {
                    cmd: lines[i + 1].trim(),
                    cmd_line: lines[i + 1],
                    timestamp_line: Some(lines[i]),
                    seconds,
                });
                i += 2;
            } else {
                // Standalone or invalid # line — skip
                i += 1;
            }
        } else {
            entries.push(Entry {
                cmd: lines[i].trim(),
                cmd_line: lines[i],
                timestamp_line: None,
                seconds: None,
            });
            i += 1;
        }
    }
    entries
}

// ── filtering ──

fn is_noise(cmd: &str, prefixes: &HashSet<String>) -> bool {
    let first = cmd.split_whitespace().next().unwrap_or("");
    let lower = first.to_ascii_lowercase();

    if prefixes.contains(&lower) {
        return true;
    }

    for p in prefixes.iter() {
        if lower.len() > p.len() && lower.starts_with(p.as_str()) {
            if let Some(b) = lower.as_bytes().get(p.len()) {
                if !b.is_ascii_alphanumeric() {
                    return true;
                }
            }
        }
    }
    false
}

fn is_wrong(cmd: &str) -> bool {
    let first = cmd.split_whitespace().next().unwrap_or("");
    if first.is_empty() {
        return true;
    }
    if !first.is_ascii() {
        return true;
    }
    if first.len() == 1 {
        return true;
    }
    false
}

// ── noise set ──

fn noise_set() -> HashSet<String> {
    let mut set: HashSet<String> = NOISE.iter().map(|s| s.to_string()).collect();

    if let Ok(extra) = env::var("BASHISTORY_EXTRA_NOISE") {
        for word in extra.split_whitespace() {
            let word = word.trim();
            if !word.is_empty() {
                set.insert(word.to_ascii_lowercase());
            }
        }
    }

    let path = config_path();
    if let Ok(content) = fs::read_to_string(&path) {
        for line in content.lines() {
            let line = line.trim();
            if !line.is_empty() && !line.starts_with('#') {
                set.insert(line.to_ascii_lowercase());
            }
        }
    }

    set
}

// ── age ──

fn age_cutoff() -> u64 {
    let days = env::var("BASHISTORY_MAX_AGE_DAYS")
        .ok()
        .and_then(|v| v.parse::<u64>().ok())
        .unwrap_or(14);
    now_secs().saturating_sub(days * 86400)
}

fn parse_timestamp(line: &str) -> Result<u64, std::num::ParseIntError> {
    line.strip_prefix('#').unwrap_or("").trim().parse()
}

// ── state (no‑op skip) ──

fn file_changed(path: &PathBuf) -> bool {
    let meta = match fs::metadata(path) {
        Ok(m) => m,
        Err(_) => return true,
    };
    let mtime = meta
        .modified()
        .ok()
        .and_then(|t| Some(t.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()));
    let Some(mtime) = mtime else { return true };
    let size = meta.len();

    let state_path = state_path();
    let saved = match fs::read_to_string(&state_path) {
        Ok(s) => s,
        Err(_) => return true,
    };
    let mut parts = saved.trim().splitn(2, ' ');
    let save_mtime: u64 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
    let save_size: u64 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(u64::MAX);

    mtime != save_mtime || size != save_size
}

fn save_state(path: &PathBuf) {
    let state_path = state_path();
    if let Some(parent) = state_path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    if let Ok(meta) = fs::metadata(path) {
        if let Ok(mtime) = meta.modified() {
            let secs = mtime.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
            let _ = fs::write(&state_path, format!("{} {}", secs, meta.len()));
        }
    }
}

fn now_secs() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

// ── output ──

fn print_stats(stats: &Stats, path: String) {
    eprintln!("bashistory: {}", path);
    if stats.noise > 0 {
        eprintln!("  removed {} noise commands", stats.noise);
    }
    if stats.duplicates > 0 {
        eprintln!("  removed {} duplicates", stats.duplicates);
    }
    if stats.old_typos > 0 {
        eprintln!("  removed {} old typos/garbage", stats.old_typos);
    }
    if stats.empty > 0 {
        eprintln!("  removed {} empty lines", stats.empty);
    }
}

fn print_dry_run(stats: &Stats, kept: usize, path: String) {
    let removed = stats.noise + stats.duplicates + stats.old_typos + stats.empty;
    if removed == 0 {
        println!("bashistory (dry-run): nothing to clean");
    } else {
        println!("bashistory (dry-run): {}", path);
        if stats.noise > 0 {
            println!("  would remove {} noise commands", stats.noise);
        }
        if stats.duplicates > 0 {
            println!("  would remove {} duplicates", stats.duplicates);
        }
        if stats.old_typos > 0 {
            println!("  would remove {} old typos/garbage", stats.old_typos);
        }
        if stats.empty > 0 {
            println!("  would remove {} empty lines", stats.empty);
        }
        println!("  {} entries would remain (files unchanged)", kept);
    }
}

// ── paths ──

fn history_path() -> PathBuf {
    if let Ok(hf) = env::var("HISTFILE") {
        let p = PathBuf::from(&hf);
        if p.is_absolute() {
            return p;
        }
    }
    let home = env::var("HOME").unwrap_or_else(|_| "/root".into());
    PathBuf::from(home).join(".bash_history")
}

fn config_path() -> PathBuf {
    xdg_dir("XDG_CONFIG_HOME", ".config").join("bashistory").join("noise.txt")
}

fn state_path() -> PathBuf {
    xdg_dir("XDG_CACHE_HOME", ".cache").join("bashistory").join("state")
}

fn xdg_dir(env_var: &str, fallback: &str) -> PathBuf {
    let home = env::var("HOME").unwrap_or_else(|_| "/root".into());
    env::var(env_var)
        .map(PathBuf::from)
        .unwrap_or_else(|_| PathBuf::from(home).join(fallback))
}
