//! CLI formatting for `tet info` (`.tet` catalog summary).

use std::fmt::Write as _;
use std::path::Path;

use crate::catalog::{
    CHUNK_PAYLOAD_CODEC_V1, ChunkIndexEntryV1, CoordAxisV1, DATASET_DTYPE_TAG_V1,
    DatasetMetadataV1, DatasetRecordV1, FileExecutionSettingsV1, HistoryEventV1, TetFileSummaryV1,
    TetMetadataV1,
};
use crate::layout::SuperblockV1;

use super::text::{contains_ascii_case_insensitive, truncate_field};

/// Default max chunk rows in the text table (`tet info --chunks`).
pub const DEFAULT_INFO_CHUNK_TABLE_LIMIT: usize = 32;

/// Max coordinate labels printed per axis in `tet info --metadata` text mode.
pub const INFO_METADATA_VERBOSE_LABELS: usize = 12;

/// How dataset footer metadata is rendered in the default dataset table.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InfoMetadataDisplay {
    /// Under each dataset row when footer metadata exists: `dim_names`, compact `coords`, attrs.
    #[default]
    WhenPresent,
    /// Like [`WhenPresent`] but prints coordinate label previews (`--metadata`).
    Verbose,
}

/// Which sections to print in text mode (`tet info` without `--json`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[allow(clippy::struct_excessive_bools)]
pub struct InfoViewSections {
    pub layout: bool,
    pub execution: bool,
    pub datasets: bool,
    pub chunks: bool,
    pub history: bool,
}

impl InfoViewSections {
    /// Default text view: dataset catalog table only (plus file header).
    #[must_use]
    pub const fn default_table() -> Self {
        Self {
            layout: false,
            execution: false,
            datasets: true,
            chunks: false,
            history: false,
        }
    }

    /// All text sections (chunk table still obeys `chunk_limit`).
    #[must_use]
    pub const fn all() -> Self {
        Self {
            layout: true,
            execution: true,
            datasets: true,
            chunks: true,
            history: true,
        }
    }
}

/// Filters for dataset / chunk listing (case-insensitive substrings).
#[derive(Debug, Clone, Default)]
pub struct InfoListFilter {
    /// Substring on dataset name.
    pub dataset: Option<String>,
    /// Substring on dataset name or dtype label.
    pub grep: Option<String>,
}

impl InfoListFilter {
    /// True when no predicates are set.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.dataset.is_none() && self.grep.is_none()
    }

    /// Human summary for headers (empty when no predicates).
    #[must_use]
    pub fn summary(&self) -> String {
        let mut parts = Vec::new();
        if let Some(d) = &self.dataset {
            parts.push(format!("dataset~{d}"));
        }
        if let Some(g) = &self.grep {
            parts.push(format!("grep~{g}"));
        }
        parts.join(" ")
    }

    /// Whether `rec` matches all set predicates (`grep` also searches footer metadata when set).
    #[must_use]
    pub fn matches_dataset(
        &self,
        rec: &DatasetRecordV1,
        ds_meta: Option<&DatasetMetadataV1>,
    ) -> bool {
        if let Some(needle) = self.dataset.as_deref()
            && !contains_ascii_case_insensitive(&rec.name, needle)
        {
            return false;
        }
        if let Some(needle) = self.grep.as_deref() {
            let hay = dataset_grep_haystack(rec, ds_meta);
            if !contains_ascii_case_insensitive(&hay, needle) {
                return false;
            }
        }
        true
    }
}

/// Resolve text sections from CLI flags (`--all`, `--layout`, …).
#[must_use]
#[allow(clippy::fn_params_excessive_bools)]
pub fn info_view_sections_from_flags(
    all: bool,
    layout: bool,
    execution: bool,
    datasets: bool,
    chunks: bool,
    history: bool,
) -> InfoViewSections {
    if all {
        return InfoViewSections::all();
    }
    let any = layout || execution || datasets || chunks || history;
    if any {
        InfoViewSections {
            layout,
            execution,
            datasets,
            chunks,
            history,
        }
    } else {
        InfoViewSections::default_table()
    }
}

/// Pretty JSON envelope (`tet info --json`).
///
/// # Errors
///
/// JSON serialization error.
pub fn format_info_json(
    path: Option<&Path>,
    file_len: u64,
    summary: &TetFileSummaryV1,
    filter: Option<&InfoListFilter>,
) -> Result<String, String> {
    let filtered = filtered_summary(summary, filter);
    let out = serde_json::json!({
        "path": path.map(|p| p.display().to_string()),
        "file_len": file_len,
        "filter": filter.filter(|f| !f.is_empty()).map(InfoListFilter::summary),
        "summary": filtered,
    });
    serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
}

/// One-line summary (`tet info -q`).
#[must_use]
pub fn format_info_quiet(
    path: Option<&Path>,
    file_len: u64,
    summary: &TetFileSummaryV1,
    filter: Option<&InfoListFilter>,
) -> String {
    let filtered = filtered_summary(summary, filter);
    let path_s = path.map_or("-".to_owned(), |p| p.display().to_string());
    format!(
        "path={path_s} file_len={file_len} layout={} datasets={} chunks={} history={}",
        summary.superblock.layout_version,
        filtered.datasets.len(),
        filtered.chunks.len(),
        filtered.history.len()
    )
}

/// Multi-section text report (default `tet info`).
#[must_use]
pub fn format_info_text(
    path: Option<&Path>,
    file_len: u64,
    summary: &TetFileSummaryV1,
    filter: Option<&InfoListFilter>,
    sections: InfoViewSections,
    chunk_limit: usize,
    metadata_display: InfoMetadataDisplay,
) -> String {
    let filtered = filtered_summary(summary, filter);
    let mut out = String::new();
    if let Some(p) = path {
        let _ = writeln!(out, "file: {}", p.display());
    }
    let _ = writeln!(
        out,
        "size: {} bytes  layout_version: {}  datasets: {}  chunks: {}",
        file_len,
        summary.superblock.layout_version,
        summary.datasets.len(),
        summary.chunks.len()
    );
    if let Some(f) = filter.filter(|f| !f.is_empty()) {
        let _ = writeln!(out, "filter: {}", f.summary());
    }
    if sections.layout {
        out.push('\n');
        out.push_str(&format_layout_section(&summary.superblock));
    }
    if sections.execution {
        out.push('\n');
        out.push_str(&format_execution_section(summary.file_execution));
    }
    if sections.datasets {
        out.push('\n');
        if let Some(file_meta) = &filtered.metadata.file {
            out.push_str(&format_file_metadata_section(file_meta));
            out.push('\n');
        }
        out.push_str(&format_datasets_table(
            &filtered.datasets,
            &filtered.chunks,
            &filtered.metadata,
            filter,
            metadata_display,
        ));
    }
    if sections.chunks {
        out.push('\n');
        out.push_str(&format_chunks_table(
            &filtered.datasets,
            &filtered.chunks,
            chunk_limit,
        ));
    }
    if sections.history {
        out.push('\n');
        out.push_str(&format_history_section(&filtered.history));
    }
    if !out.ends_with('\n') {
        out.push('\n');
    }
    out
}

fn filtered_summary(
    summary: &TetFileSummaryV1,
    filter: Option<&InfoListFilter>,
) -> TetFileSummaryV1 {
    let Some(f) = filter.filter(|x| !x.is_empty()) else {
        return summary.clone();
    };
    let mut datasets = Vec::new();
    let mut id_map = std::collections::HashMap::new();
    for (old_id, rec) in summary.datasets.iter().enumerate() {
        let ds_meta = summary.metadata.datasets.get(&rec.name);
        if f.matches_dataset(rec, ds_meta) {
            id_map.insert(old_id as u64, datasets.len() as u64);
            datasets.push(rec.clone());
        }
    }
    let chunks = summary
        .chunks
        .iter()
        .filter_map(|c| {
            let new_id = *id_map.get(&c.dataset_id)?;
            let mut ch = c.clone();
            ch.dataset_id = new_id;
            Some(ch)
        })
        .collect();
    let metadata = filter_metadata_for_datasets(&summary.metadata, &datasets);
    TetFileSummaryV1 {
        superblock: summary.superblock.clone(),
        datasets,
        chunks,
        file_execution: summary.file_execution,
        history: summary.history.clone(),
        metadata,
    }
}

fn filter_metadata_for_datasets(
    meta: &TetMetadataV1,
    datasets: &[DatasetRecordV1],
) -> TetMetadataV1 {
    let names: std::collections::HashSet<&str> = datasets.iter().map(|d| d.name.as_str()).collect();
    let datasets: std::collections::BTreeMap<_, _> = meta
        .datasets
        .iter()
        .filter(|(k, _)| names.contains(k.as_str()))
        .map(|(k, v)| (k.clone(), v.clone()))
        .collect();
    if meta.file.is_none() && datasets.is_empty() {
        return TetMetadataV1::default();
    }
    TetMetadataV1 {
        file: meta.file.clone(),
        datasets,
    }
}

fn format_layout_section(sb: &SuperblockV1) -> String {
    let mut out = String::new();
    out.push_str("layout:\n");
    let _ = writeln!(out, "  layout_version:    {}", sb.layout_version);
    let _ = writeln!(out, "  dataset_count:     {}", sb.dataset_count);
    let _ = writeln!(
        out,
        "  flags:             {} (history_footer={})",
        sb.flags,
        sb.flags & crate::layout::SUPERBLOCK_FLAG_HISTORY_FOOTER != 0
    );
    let _ = writeln!(out, "  chunk_index_offset: {}", sb.chunk_index_offset);
    let _ = writeln!(out, "  chunk_index_length: {}", sb.chunk_index_length);
    out
}

fn format_execution_section(ex: FileExecutionSettingsV1) -> String {
    let mut out = String::new();
    out.push_str("execution (file defaults):\n");
    if ex.memory_budget_bytes == 0 && ex.memory_budget_percent_bps == 0 {
        out.push_str("  (engine defaults — 25% host RAM when unset in query JSON)\n");
        return out;
    }
    if ex.memory_budget_percent_bps != 0 {
        let pct = f64::from(ex.memory_budget_percent_bps) / 100.0;
        let _ = writeln!(out, "  memory_budget_percent: {pct}");
    }
    if ex.memory_budget_bytes != 0 {
        let _ = writeln!(out, "  memory_budget_bytes: {}", ex.memory_budget_bytes);
    }
    out
}

fn format_file_metadata_section(file: &crate::catalog::FileMetadataV1) -> String {
    let mut out = String::new();
    out.push_str("file metadata:\n");
    if let Some(tool) = &file.tool {
        let _ = writeln!(out, "  tool: {tool}");
    }
    if let Some(ver) = &file.library_version {
        let _ = writeln!(out, "  library_version: {ver}");
    }
    if let Some(at) = &file.created_at {
        let _ = writeln!(out, "  created_at: {at}");
    }
    out
}

fn dataset_grep_haystack(rec: &DatasetRecordV1, ds_meta: Option<&DatasetMetadataV1>) -> String {
    let mut hay = format!("{} {}", rec.name, dtype_label(rec.dtype));
    let Some(m) = ds_meta else {
        return hay;
    };
    if let Some(dim_names) = &m.dim_names {
        hay.push(' ');
        hay.push_str(&dim_names.join(" "));
    }
    for (k, v) in &m.attrs {
        hay.push(' ');
        hay.push_str(k);
        hay.push(' ');
        hay.push_str(v);
    }
    if let Some(coords) = &m.coords {
        for (axis, c) in coords {
            hay.push(' ');
            hay.push_str(axis);
            for label in &c.labels {
                hay.push(' ');
                hay.push_str(label);
            }
        }
    }
    hay
}

fn format_datasets_table(
    datasets: &[DatasetRecordV1],
    chunks: &[ChunkIndexEntryV1],
    metadata: &TetMetadataV1,
    filter: Option<&InfoListFilter>,
    metadata_display: InfoMetadataDisplay,
) -> String {
    let mut out = String::new();
    if datasets.is_empty() {
        if filter.is_some_and(|f| !f.is_empty()) {
            out.push_str("datasets: (no rows match filter)\n");
        } else {
            out.push_str("datasets: (empty file)\n");
        }
        return out;
    }
    out.push_str("datasets:\n");
    let _ = writeln!(
        out,
        "  {:>3}  {:<20}  {:<5}  {:<16}  {:<12}  chunks",
        "id", "name", "dtype", "shape", "chunk_shape"
    );
    for (id, ds) in datasets.iter().enumerate() {
        let n_chunks = chunks.iter().filter(|c| c.dataset_id == id as u64).count();
        let _ = writeln!(
            out,
            "  {:>3}  {:<20}  {:<5}  {:<16}  {:<12}  {}",
            id,
            truncate_field(&ds.name, 20),
            dtype_label(ds.dtype),
            shape_label(&ds.shape),
            shape_label(&ds.chunk_shape),
            n_chunks
        );
        if let Some(ds_meta) = metadata.datasets.get(&ds.name) {
            append_dataset_metadata_lines(&mut out, ds_meta, metadata_display);
        }
    }
    out
}

fn append_dataset_metadata_lines(
    out: &mut String,
    ds_meta: &DatasetMetadataV1,
    display: InfoMetadataDisplay,
) {
    if let Some(dim_names) = &ds_meta.dim_names {
        let _ = writeln!(out, "       dim_names: {}", dim_names.join(", "));
    }
    if let Some(coords) = &ds_meta.coords {
        match display {
            InfoMetadataDisplay::WhenPresent => {
                let summary: Vec<String> = coords
                    .iter()
                    .map(|(axis, c)| format!("{axis}({} labels)", c.labels.len()))
                    .collect();
                let _ = writeln!(out, "       coords: {}", summary.join(", "));
            }
            InfoMetadataDisplay::Verbose => {
                for (axis, c) in coords {
                    let _ = writeln!(out, "       {}", format_coord_axis_verbose(axis, c));
                }
            }
        }
    }
    for (k, v) in &ds_meta.attrs {
        let _ = writeln!(out, "       {k}: {v}");
    }
}

fn format_coord_axis_verbose(axis: &str, c: &CoordAxisV1) -> String {
    let labels = &c.labels;
    if labels.is_empty() {
        return format!("{axis}: (empty)");
    }
    let show = INFO_METADATA_VERBOSE_LABELS.min(labels.len());
    let head = labels[..show].join(", ");
    if labels.len() > show {
        format!("{axis}: {head} … (+{} more)", labels.len() - show)
    } else {
        format!("{axis}: {head}")
    }
}

fn format_chunks_table(
    datasets: &[DatasetRecordV1],
    chunks: &[ChunkIndexEntryV1],
    limit: usize,
) -> String {
    let mut out = String::new();
    if chunks.is_empty() {
        out.push_str("chunks: (none)\n");
        return out;
    }
    let show = if limit == 0 {
        chunks.len()
    } else {
        limit.min(chunks.len())
    };
    out.push_str("chunks:\n");
    if show < chunks.len() {
        let _ = writeln!(
            out,
            "  (showing {show} of {}; use -n 0 for all)",
            chunks.len()
        );
    }
    let _ = writeln!(
        out,
        "  {:>4}  {:<12}  {:<8}  {:>10}  {:>10}  {:>5}  offset",
        "#", "dataset", "coords", "raw", "stored", "codec"
    );
    for (i, ch) in chunks.iter().take(show).enumerate() {
        let ds_name = datasets
            .get(ch.dataset_id as usize)
            .map_or("?", |d| d.name.as_str());
        let _ = writeln!(
            out,
            "  {:>4}  {:<12}  {:<8}  {:>10}  {:>10}  {:>5}  {}",
            i,
            truncate_field(ds_name, 12),
            chunk_coords_label(ch, datasets.get(ch.dataset_id as usize)),
            ch.raw_byte_len,
            ch.stored_byte_len,
            codec_label(ch.codec),
            ch.payload_offset
        );
    }
    out
}

fn format_history_section(history: &[HistoryEventV1]) -> String {
    let mut out = String::new();
    out.push_str("history:\n");
    if history.is_empty() {
        out.push_str("  (none)\n");
        return out;
    }
    for (i, ev) in history.iter().enumerate() {
        let _ = writeln!(
            out,
            "  {:>3}  {}  {}  at={}",
            i + 1,
            ev.op,
            ev.source,
            ev.at
        );
        if !ev.parents.is_empty() {
            let _ = writeln!(out, "       parents: {}", ev.parents.join(", "));
        }
        for (k, v) in &ev.params {
            let _ = writeln!(out, "       {k}: {v}");
        }
    }
    out
}

fn dtype_label(tag: u32) -> &'static str {
    let t = DATASET_DTYPE_TAG_V1;
    if t.is_f32(tag) {
        "f32"
    } else if t.is_f64(tag) {
        "f64"
    } else if t.is_i32(tag) {
        "i32"
    } else if t.is_i64(tag) {
        "i64"
    } else {
        "?"
    }
}

fn codec_label(codec: u32) -> &'static str {
    let c = CHUNK_PAYLOAD_CODEC_V1;
    if c.is_raw(codec) {
        "raw"
    } else if c.is_zstd(codec) {
        "zstd"
    } else {
        "?"
    }
}

fn shape_label(shape: &[u64]) -> String {
    if shape.is_empty() {
        return "-".to_owned();
    }
    shape
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join("×")
}

fn chunk_coords_label(ch: &ChunkIndexEntryV1, ds: Option<&DatasetRecordV1>) -> String {
    let rank = ds.map_or(0, |d| d.shape.len());
    if rank == 0 {
        return "-".to_owned();
    }
    ch.chunk_index[..rank]
        .iter()
        .map(ToString::to_string)
        .collect::<Vec<_>>()
        .join(",")
}
