//! gssize — directory size breakdown CLI (English code; localized stdout tables).

mod l10n;

use clap::Parser;
use l10n::UiLang;
use std::cmp::Ordering;
use std::env;
use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use terminal_size::{terminal_size, Width};
use thiserror::Error;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use walkdir::WalkDir;

#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum Category {
    Image,
    Audio,
    Video,
    Code,
    Other,
}

impl Category {
    const ALL: [Category; 5] = [
        Category::Image,
        Category::Audio,
        Category::Video,
        Category::Code,
        Category::Other,
    ];

    fn idx(self) -> usize {
        match self {
            Category::Image => 0,
            Category::Audio => 1,
            Category::Video => 2,
            Category::Code => 3,
            Category::Other => 4,
        }
    }
}

#[derive(Debug, Error)]
enum AppError {
    #[error("input path is not a directory or is unreadable: {0}")]
    InputDir(PathBuf),
    #[error("{0}")]
    Io(#[from] std::io::Error),
}

#[derive(Parser, Debug)]
#[command(name = "gssize", about = "List file sizes under a directory with category totals and optional image dimensions.")]
struct Cli {
    /// Root directory to scan (recursively)
    #[arg(short, long)]
    input: PathBuf,

    /// Follow symbolic links when walking the tree
    #[arg(long, default_value_t = false)]
    follow_symlinks: bool,

    /// Max rows in the global file table and in each category table (0 = skip global table only; each category still lists all its files)
    #[arg(long, default_value_t = 15)]
    max_files: usize,
}

struct FileEntry {
    rel: PathBuf,
    size: u64,
    category: Category,
    dims: Option<(u32, u32)>,
}

fn main() -> Result<(), AppError> {
    let cli = Cli::parse();
    let lang = UiLang::detect();
    run(&cli, lang)?;
    Ok(())
}

fn run(cli: &Cli, lang: UiLang) -> Result<(), AppError> {
    let input = fs::canonicalize(&cli.input).map_err(|_| AppError::InputDir(cli.input.clone()))?;
    if !input.is_dir() {
        return Err(AppError::InputDir(input));
    }

    let mut files = collect_files(&input, cli.follow_symlinks)?;
    if files.is_empty() {
        let stdout = io::stdout();
        let mut out = stdout.lock();
        writeln!(out, "{}", lang.param_line(cli.max_files))?;
        writeln!(out, "{}: {}", lang.root_label(), input.display())?;
        writeln!(out, "{}", lang.units_note())?;
        writeln!(out)?;
        writeln!(out, "{}", lang.no_files())?;
        return Ok(());
    }

    let g: u64 = files.iter().map(|f| f.size).sum();
    let mut per_cat = [0u64; 5];
    for f in &files {
        per_cat[f.category.idx()] += f.size;
    }

    for f in &mut files {
        if f.category == Category::Image {
            f.dims = read_image_dims(&input.join(&f.rel), f.rel.extension().and_then(|e| e.to_str()));
        }
    }

    files.sort_by(|a, b| {
        b.size
            .cmp(&a.size)
            .then_with(|| cmp_path_lossy(&a.rel, &b.rel))
    });

    let stdout = io::stdout();
    let mut out = stdout.lock();
    let path_col_dw = resolve_path_column_width();
    let file_table_width = file_table_line_display_width(path_col_dw);

    writeln!(out, "{}", lang.param_line(cli.max_files))?;
    writeln!(out, "{}: {}", lang.root_label(), input.display())?;
    writeln!(out, "{}", lang.units_note())?;
    writeln!(out)?;

    print_summary(&mut out, lang, g, &per_cat)?;
    writeln!(out)?;

    if cli.max_files > 0 {
        writeln!(out, "{}", lang.section_global_files(cli.max_files))?;
        print_file_table_header(&mut out, lang, path_col_dw)?;
        print_separator_line(&mut out, file_table_width)?;
        for f in files.iter().take(cli.max_files) {
            print_file_row(&mut out, lang, f, g, per_cat[f.category.idx()], path_col_dw)?;
        }
        writeln!(out)?;
    }

    let per_cat_take = if cli.max_files == 0 {
        usize::MAX
    } else {
        cli.max_files
    };
    for cat in Category::ALL {
        let mut sub: Vec<&FileEntry> = files.iter().filter(|f| f.category == cat).collect();
        sub.sort_by(|a, b| {
            b.size
                .cmp(&a.size)
                .then_with(|| cmp_path_lossy(&a.rel, &b.rel))
        });
        if sub.is_empty() {
            continue;
        }
        let csum = per_cat[cat.idx()];
        writeln!(out, "{}", lang.section_category_top(cat, cli.max_files))?;
        print_file_table_header(&mut out, lang, path_col_dw)?;
        print_separator_line(&mut out, file_table_width)?;
        for f in sub.iter().take(per_cat_take) {
            print_file_row(&mut out, lang, f, g, csum, path_col_dw)?;
        }
        writeln!(out)?;
    }

    Ok(())
}

fn cmp_path_lossy(a: &Path, b: &Path) -> Ordering {
    a.to_string_lossy().cmp(&b.to_string_lossy())
}

const GAP: &str = "  ";
const SUMMARY_LINE_WIDTH: usize = 41;

// Summary: name display 14, size & pct columns use display width
const S_NAME_DW: usize = 14;
const S_SIZE_BW: usize = 12;
const S_PCT_BW: usize = 11;

// File table: path column at least this many display columns when width unknown or narrow
const F_PATH_DW_MIN: usize = 40;
const F_SIZE_BW: usize = 12;
const F_G_BW: usize = 9;
const F_C_BW: usize = 9;
const F_DIM_DW: usize = 12;

/// Fixed suffix after path: gap + size + gap + %g + gap + %c + gap + dim (all display widths).
const FILE_SUFFIX_DW: usize = 2 + F_SIZE_BW + 2 + F_G_BW + 2 + F_C_BW + 2 + F_DIM_DW;

fn resolve_path_column_width() -> usize {
    let cols = terminal_size()
        .map(|(Width(w), _)| w as usize)
        .or_else(|| env::var("COLUMNS").ok().and_then(|s| s.parse().ok()))
        .filter(|&c| c > 0)
        .unwrap_or(0);
    if cols == 0 {
        return F_PATH_DW_MIN;
    }
    cols.saturating_sub(FILE_SUFFIX_DW).max(F_PATH_DW_MIN)
}

fn file_table_line_display_width(path_col_dw: usize) -> usize {
    path_col_dw.saturating_add(FILE_SUFFIX_DW)
}

/// Pad to `width` display columns; if wider, truncate with ellipsis (for short fixed columns).
fn pad_display(s: &str, width: usize, right: bool) -> String {
    let fitted = if s.width() <= width {
        s.to_string()
    } else {
        clamp_display_width(s, width)
    };
    let w = fitted.width();
    let fitted = if w > width {
        clamp_display_width(&fitted, width)
    } else {
        fitted
    };
    let w = fitted.width();
    if w == width {
        return fitted;
    }
    let pad = width.saturating_sub(w);
    if right {
        format!("{}{}", " ".repeat(pad), fitted)
    } else {
        format!("{}{}", fitted, " ".repeat(pad))
    }
}

/// Pad only when narrower than `width`; never truncate (for wrapped path segments).
fn pad_display_min_only(s: &str, width: usize, right: bool) -> String {
    let w = s.width();
    if w >= width {
        return s.to_string();
    }
    let pad = width - w;
    if right {
        format!("{}{}", " ".repeat(pad), s)
    } else {
        format!("{}{}", s, " ".repeat(pad))
    }
}

/// Truncate to at most `max_width` display columns (ellipsis if truncated).
fn clamp_display_width(s: &str, max_width: usize) -> String {
    if s.width() <= max_width {
        return s.to_string();
    }
    const ELLIPSIS: &str = "…";
    let ew = ELLIPSIS.width();
    let budget = max_width.saturating_sub(ew);
    if budget == 0 {
        return ".".repeat(max_width);
    }
    let mut acc = 0usize;
    let mut out = String::new();
    for ch in s.chars() {
        let cw = ch.width().unwrap_or(0);
        if acc + cw > budget {
            break;
        }
        out.push(ch);
        acc += cw;
    }
    if out.is_empty() {
        return ".".repeat(max_width.min(3));
    }
    format!("{out}{ELLIPSIS}")
}

/// Split `s` into lines of at most `max_dw` display width each (no ellipsis).
fn wrap_display_to_lines(s: &str, max_dw: usize) -> Vec<String> {
    if max_dw == 0 {
        return if s.is_empty() {
            vec![String::new()]
        } else {
            vec![s.to_string()]
        };
    }
    if s.is_empty() {
        return vec![String::new()];
    }
    let mut lines = Vec::new();
    let mut rest = s;
    while !rest.is_empty() {
        let (line, next) = take_display_prefix(rest, max_dw);
        lines.push(line);
        rest = next;
    }
    lines
}

/// One line of at most `max_dw` display columns; remainder (may be empty) after `split` byte index.
fn take_display_prefix(s: &str, max_dw: usize) -> (String, &str) {
    let mut acc = 0usize;
    let mut split_at = 0usize;
    for (i, ch) in s.char_indices() {
        let cw = ch.width().unwrap_or(0);
        if acc + cw > max_dw {
            break;
        }
        split_at = i + ch.len_utf8();
        acc += cw;
    }
    if split_at == 0 && !s.is_empty() {
        let ch = s.chars().next().expect("non-empty");
        let end = ch.len_utf8();
        return (s[..end].to_string(), &s[end..]);
    }
    (s[..split_at].to_string(), &s[split_at..])
}

fn center_display(s: &str, width: usize) -> String {
    let w = s.width();
    if w > width {
        return clamp_display_width(s, width);
    }
    if w == width {
        return s.to_string();
    }
    let pad = width - w;
    let left = pad / 2;
    let right = pad - left;
    format!("{}{}{}", " ".repeat(left), s, " ".repeat(right))
}

fn format_size_binary(n: u64) -> String {
    const K: u64 = 1024;
    if n >= K * K * K {
        format!("{:.2} GiB", n as f64 / (K * K * K) as f64)
    } else if n >= K * K {
        format!("{:.2} MiB", n as f64 / (K * K) as f64)
    } else if n >= K {
        format!("{:.2} KiB", n as f64 / K as f64)
    } else {
        format!("{n} B")
    }
}

fn pct(part: u64, total: u64) -> String {
    if total == 0 {
        return "0.00%".to_string();
    }
    format!("{:.2}%", 100.0 * part as f64 / total as f64)
}

fn print_summary(out: &mut impl Write, lang: UiLang, g: u64, per_cat: &[u64; 5]) -> io::Result<()> {
    writeln!(out, "{}", lang.section_summary())?;
    let hdr = format!(
        "{}{}{}{}{}",
        pad_display(lang.hdr_name(), S_NAME_DW, false),
        GAP,
        center_display(lang.hdr_size(), S_SIZE_BW),
        GAP,
        pad_display(lang.hdr_of_total(), S_PCT_BW, true)
    );
    writeln!(out, "{hdr}")?;
    writeln!(out, "{}", "-".repeat(SUMMARY_LINE_WIDTH))?;

    let row = |name: &str, sz: &str, p: &str| {
        format!(
            "{}{}{}{}{}",
            pad_display(name, S_NAME_DW, false),
            GAP,
            pad_display(sz, S_SIZE_BW, true),
            GAP,
            pad_display(p, S_PCT_BW, true)
        )
    };

    writeln!(
        out,
        "{}",
        row(
            lang.label_total(),
            &format_size_binary(g),
            &pct(g, g)
        )
    )?;
    for cat in Category::ALL {
        let s = per_cat[cat.idx()];
        writeln!(
            out,
            "{}",
            row(
                lang.category_label(cat),
                &format_size_binary(s),
                &pct(s, g)
            )
        )?;
    }
    Ok(())
}

fn print_file_table_header(out: &mut impl Write, lang: UiLang, path_col_dw: usize) -> io::Result<()> {
    let line = format!(
        "{}{}{}{}{}{}{}{}{}",
        pad_display(lang.hdr_path(), path_col_dw, false),
        GAP,
        center_display(lang.hdr_size(), F_SIZE_BW),
        GAP,
        pad_display(lang.hdr_pct_global(), F_G_BW, true),
        GAP,
        pad_display(lang.hdr_pct_cat(), F_C_BW, true),
        GAP,
        pad_display(lang.hdr_dim(), F_DIM_DW, false)
    );
    writeln!(out, "{line}")
}

fn print_separator_line(out: &mut impl Write, width: usize) -> io::Result<()> {
    writeln!(out, "{}", "-".repeat(width))
}

fn file_row_data_suffix(
    size: &str,
    pct_global: &str,
    pct_cat: &str,
    dim: &str,
) -> String {
    format!(
        "{}{}{}{}{}{}{}{}",
        GAP,
        pad_display(size, F_SIZE_BW, true),
        GAP,
        pad_display(pct_global, F_G_BW, true),
        GAP,
        pad_display(pct_cat, F_C_BW, true),
        GAP,
        dim
    )
}

fn print_file_row(
    out: &mut impl Write,
    _lang: UiLang,
    f: &FileEntry,
    g: u64,
    cat_total: u64,
    path_col_dw: usize,
) -> io::Result<()> {
    let rel = f.rel.to_string_lossy();
    let path_lines = wrap_display_to_lines(rel.as_ref(), path_col_dw);
    let dim_s = f
        .dims
        .map(|(w, h)| format!("{w}×{h}"))
        .unwrap_or_else(|| "-".to_string());
    let dim_cell = pad_display(&dim_s, F_DIM_DW, false);
    let size_s = format_size_binary(f.size);
    let pg = pct(f.size, g);
    let pc = pct(f.size, cat_total.max(1));
    let data_suffix = file_row_data_suffix(&size_s, &pg, &pc, &dim_cell);

    for (i, seg) in path_lines.iter().enumerate() {
        if i == 0 {
            let path_cell = pad_display_min_only(seg, path_col_dw, false);
            writeln!(out, "{path_cell}{data_suffix}")?;
        } else {
            writeln!(out, "{seg}")?;
        }
    }
    Ok(())
}

fn collect_files(input: &Path, follow_links: bool) -> Result<Vec<FileEntry>, AppError> {
    let mut out = Vec::new();
    for ent in WalkDir::new(input)
        .follow_links(follow_links)
        .into_iter()
        .filter_map(|e| e.ok())
    {
        let path = ent.path();
        if !path.is_file() {
            continue;
        }
        let meta = match fs::metadata(path) {
            Ok(m) => m,
            Err(e) => {
                eprintln!("skip metadata ({}): {e}", path.display());
                continue;
            }
        };
        let size = meta.len();
        let rel = path.strip_prefix(input).unwrap_or(path).to_path_buf();
        let ext = path
            .extension()
            .and_then(|e| e.to_str())
            .map(|s| s.to_ascii_lowercase());
        let category = classify_ext(ext.as_deref());
        out.push(FileEntry {
            rel,
            size,
            category,
            dims: None,
        });
    }
    Ok(out)
}

fn classify_ext(ext: Option<&str>) -> Category {
    let Some(ext) = ext else {
        return Category::Other;
    };
    const IMG: &[&str] = &[
        "png", "jpg", "jpeg", "webp", "gif", "bmp", "ico", "tiff", "tif", "heic", "ktx", "ktx2",
        "dds", "basis", "astc", "pkm", "pvr",
    ];
    const AUD: &[&str] = &[
        "mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus",
    ];
    const VID: &[&str] = &["mp4", "webm", "mkv", "mov", "avi", "m4v"];
    const CODE: &[&str] = &[
        "rs", "go", "ts", "tsx", "js", "jsx", "py", "java", "kt", "swift", "cpp", "cc", "cxx",
        "h", "hpp", "c", "cs", "php", "rb", "sh", "sql", "toml", "json", "yaml", "yml", "css",
        "html", "htm", "vue", "svelte", "zig",
    ];
    if IMG.contains(&ext) {
        Category::Image
    } else if AUD.contains(&ext) {
        Category::Audio
    } else if VID.contains(&ext) {
        Category::Video
    } else if CODE.contains(&ext) {
        Category::Code
    } else {
        Category::Other
    }
}

fn read_image_dims(path: &Path, ext: Option<&str>) -> Option<(u32, u32)> {
    let ext = ext.map(|e| e.to_ascii_lowercase())?;
    match ext.as_str() {
        "dds" => dds_dimensions(path),
        "ktx2" => ktx2_dimensions(path),
        _ => image::image_dimensions(path).ok(),
    }
}

fn dds_dimensions(path: &Path) -> Option<(u32, u32)> {
    let data = fs::read(path).ok()?;
    if data.len() < 128 || data.get(0..4)? != b"DDS " {
        return None;
    }
    let h = u32::from_le_bytes(data.get(12..16)?.try_into().ok()?);
    let w = u32::from_le_bytes(data.get(16..20)?.try_into().ok()?);
    if w == 0 || h == 0 {
        return None;
    }
    Some((w, h))
}

fn ktx2_dimensions(path: &Path) -> Option<(u32, u32)> {
    let data = fs::read(path).ok()?;
    let reader = ktx2::Reader::new(&data[..]).ok()?;
    let h = reader.header();
    let w = h.pixel_width;
    let height = h.pixel_height;
    if w == 0 || height == 0 {
        return None;
    }
    Some((w, height))
}
