//! `code_files` — counts tracked files in the discovered git repo's index and ranks the
//! top-level directories that hold them. `Text` is the headline file count;
//! `TextBlock` / `Entries` / `Bars` / `MarkdownTextBlock` all carry the per-top-level-dir
//! breakdown. Vendored / generated trees are skipped via the family-wide exclusion list.

use std::collections::HashMap;

use async_trait::async_trait;

use crate::payload::{
    Bar, BarsData, Body, EntriesData, Entry, MarkdownTextBlockData, Payload, TextBlockData,
    TextData,
};
use crate::render::Shape;
use crate::samples;

use super::super::git::{open_repo, payload, repo_cache_key};
use super::super::{FetchContext, FetchError, Fetcher, Safety};
use super::scan::for_each_tracked_path;

const SHAPES: &[Shape] = &[
    Shape::Text,
    Shape::TextBlock,
    Shape::Entries,
    Shape::Bars,
    Shape::MarkdownTextBlock,
];
const TOP_LEVEL_LIMIT: usize = 10;
const ROOT_LABEL: &str = "(root)";

pub struct CodeFiles;

#[async_trait]
impl Fetcher for CodeFiles {
    fn name(&self) -> &str {
        "code_files"
    }
    fn safety(&self) -> Safety {
        Safety::Safe
    }
    fn description(&self) -> &'static str {
        "Counts tracked files in the discovered git repo's index and ranks top-level directories by file count. `Text` is the headline file count (`\"342 files\"`); `TextBlock` lists `\"<dir> (<count>)\"` rows; `Entries` exposes the same as key/value rows; `Bars` ranks them as bar values; `MarkdownTextBlock` formats them as a Markdown bullet list with emphasis. Files at the repo root are grouped under `\"(root)\"`. Vendored / generated dirs (`node_modules` / `vendor` / `target` / `dist` / `build`) are filtered out even when committed."
    }
    fn refresh_interval(&self) -> u64 {
        60 * 5
    }
    fn shapes(&self) -> &[Shape] {
        SHAPES
    }
    fn default_shape(&self) -> Shape {
        Shape::Text
    }
    fn cache_key(&self, ctx: &FetchContext) -> String {
        repo_cache_key(self.name(), ctx)
    }
    fn sample_body(&self, shape: Shape) -> Option<Body> {
        Some(match shape {
            Shape::Text => samples::text("342 files"),
            Shape::TextBlock => {
                samples::text_block(&["src (180)", "tests (42)", "docs (18)", "(root) (5)"])
            }
            Shape::Entries => samples::entries(&[
                ("src", "180"),
                ("tests", "42"),
                ("docs", "18"),
                ("(root)", "5"),
            ]),
            Shape::Bars => samples::bars(&[("src", 180), ("tests", 42), ("docs", 18)]),
            Shape::MarkdownTextBlock => samples::markdown(
                "- **src** — 180 files\n- **tests** — 42 files\n- **docs** — 18 files",
            ),
            _ => return None,
        })
    }
    async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError> {
        let scan = scan_cwd()?;
        Ok(payload(render_body(scan, ctx.shape.unwrap_or(Shape::Text))))
    }
}

#[derive(Debug, Default)]
struct Scan {
    file_count: usize,
    top_level: Vec<(String, u64)>,
}

fn scan_cwd() -> Result<Scan, FetchError> {
    let repo = open_repo()?;
    scan_repo(&repo)
}

fn scan_repo(repo: &gix::Repository) -> Result<Scan, FetchError> {
    let mut file_count = 0usize;
    let mut top_counts: HashMap<String, u64> = HashMap::new();
    for_each_tracked_path(repo, |path| {
        file_count += 1;
        *top_counts.entry(top_level_key(path)).or_insert(0) += 1;
    })?;
    let mut top_level: Vec<_> = top_counts.into_iter().collect();
    top_level.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
    Ok(Scan {
        file_count,
        top_level,
    })
}

fn top_level_key(path: &str) -> String {
    match path.split_once('/') {
        Some((head, _)) => head.to_string(),
        None => ROOT_LABEL.to_string(),
    }
}

fn render_body(scan: Scan, shape: Shape) -> Body {
    if scan.file_count == 0 {
        return empty_body(shape);
    }
    match shape {
        Shape::TextBlock => text_block_body(&scan),
        Shape::Entries => entries_body(&scan),
        Shape::Bars => bars_body(scan),
        Shape::MarkdownTextBlock => markdown_body(&scan),
        _ => text_body(&scan),
    }
}

fn empty_body(shape: Shape) -> Body {
    match shape {
        Shape::TextBlock => Body::TextBlock(TextBlockData { lines: vec![] }),
        Shape::Entries => Body::Entries(EntriesData { items: vec![] }),
        Shape::Bars => Body::Bars(BarsData { bars: vec![] }),
        Shape::MarkdownTextBlock => Body::MarkdownTextBlock(MarkdownTextBlockData {
            value: String::new(),
        }),
        _ => Body::Text(TextData {
            value: String::new(),
        }),
    }
}

fn text_body(scan: &Scan) -> Body {
    Body::Text(TextData {
        value: format!("{} {}", scan.file_count, files_word(scan.file_count)),
    })
}

fn text_block_body(scan: &Scan) -> Body {
    Body::TextBlock(TextBlockData {
        lines: top_level_iter(&scan.top_level)
            .map(|(label, count)| format!("{label} ({count})"))
            .collect(),
    })
}

fn entries_body(scan: &Scan) -> Body {
    Body::Entries(EntriesData {
        items: top_level_iter(&scan.top_level)
            .map(|(label, count)| Entry {
                key: label.to_string(),
                value: Some(count.to_string()),
                status: None,
            })
            .collect(),
    })
}

fn bars_body(scan: Scan) -> Body {
    Body::Bars(BarsData {
        bars: scan
            .top_level
            .into_iter()
            .take(TOP_LEVEL_LIMIT)
            .map(|(label, value)| Bar {
                label,
                value,
                value_label: None,
            })
            .collect(),
    })
}

fn markdown_body(scan: &Scan) -> Body {
    Body::MarkdownTextBlock(MarkdownTextBlockData {
        value: top_level_iter(&scan.top_level)
            .map(|(label, count)| format!("- **{label}** — {count} {}", files_word(count as usize)))
            .collect::<Vec<_>>()
            .join("\n"),
    })
}

fn top_level_iter(top_level: &[(String, u64)]) -> impl Iterator<Item = (&str, u64)> {
    top_level
        .iter()
        .take(TOP_LEVEL_LIMIT)
        .map(|(k, v)| (k.as_str(), *v))
}

fn files_word(n: usize) -> &'static str {
    if n == 1 { "file" } else { "files" }
}

#[cfg(test)]
mod tests {
    use super::super::super::git::test_support::{commit_touching, make_repo};
    use super::*;

    fn ctx(shape: Option<Shape>) -> FetchContext {
        FetchContext {
            widget_id: "widget".into(),
            shape,
            ..Default::default()
        }
    }

    fn fixed_scan() -> Scan {
        Scan {
            file_count: 220,
            top_level: vec![
                ("src".into(), 180),
                ("tests".into(), 30),
                ("docs".into(), 9),
                (ROOT_LABEL.into(), 1),
            ],
        }
    }

    #[test]
    fn fetcher_contract_and_samples_cover_supported_shapes() {
        assert_eq!(CodeFiles.name(), "code_files");
        assert!(matches!(CodeFiles.safety(), Safety::Safe));
        assert!(CodeFiles.description().contains("top-level directories"));
        assert_eq!(CodeFiles.default_shape(), Shape::Text);
        assert_eq!(CodeFiles.shapes(), SHAPES);
        assert_ne!(
            CodeFiles.cache_key(&ctx(Some(Shape::Text))),
            CodeFiles.cache_key(&ctx(Some(Shape::Bars)))
        );
        SHAPES.iter().copied().for_each(|shape| {
            let body = CodeFiles.sample_body(shape).expect("sample body");
            assert_eq!(crate::render::shape_of(&body), shape);
        });
        assert!(CodeFiles.sample_body(Shape::Ratio).is_none());
    }

    #[test]
    fn empty_repo_returns_empty_scan() {
        let (_tmp, repo) = make_repo();
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 0);
        assert!(scan.top_level.is_empty());
    }

    #[test]
    fn root_file_groups_under_root_label() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "README.md", "x");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level, vec![(ROOT_LABEL.into(), 1)]);
    }

    #[test]
    fn nested_path_attributes_to_top_segment() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "a/b/c/x.rs", "x");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level, vec![("a".into(), 1)]);
    }

    #[test]
    fn vendored_dirs_excluded_even_when_committed() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "src/main.rs", "x");
        commit_touching(&repo, "node_modules/foo/index.js", "y");
        commit_touching(&repo, "vendor/lib/x.go", "z");
        let scan = scan_repo(&repo).unwrap();
        assert_eq!(scan.file_count, 1);
        assert_eq!(scan.top_level[0].0, "src");
    }

    #[test]
    fn top_level_breakdown_ranks_by_count() {
        let (_tmp, repo) = make_repo();
        commit_touching(&repo, "src/a.rs", "1");
        commit_touching(&repo, "src/b.rs", "2");
        commit_touching(&repo, "src/c.rs", "3");
        commit_touching(&repo, "tests/x.rs", "4");
        commit_touching(&repo, "tests/y.rs", "5");
        commit_touching(&repo, "README.md", "6");
        let scan = scan_repo(&repo).unwrap();
        let labels: Vec<_> = scan.top_level.iter().map(|(k, _)| k.clone()).collect();
        assert_eq!(
            labels,
            vec!["src".to_string(), "tests".into(), ROOT_LABEL.into()]
        );
        assert_eq!(scan.top_level[0].1, 3);
        assert_eq!(scan.top_level[1].1, 2);
    }

    #[test]
    fn text_renders_headline_file_count_with_grammar() {
        let one = Scan {
            file_count: 1,
            top_level: vec![],
        };
        assert_eq!(
            render_body(one, Shape::Text),
            Body::Text(TextData {
                value: "1 file".into()
            })
        );
        assert_eq!(
            render_body(fixed_scan(), Shape::Text),
            Body::Text(TextData {
                value: "220 files".into()
            })
        );
    }

    #[test]
    fn text_is_empty_when_no_files() {
        assert_eq!(
            render_body(Scan::default(), Shape::Text),
            Body::Text(TextData {
                value: String::new()
            })
        );
    }

    #[test]
    fn text_block_lists_top_level_dirs() {
        assert_eq!(
            render_body(fixed_scan(), Shape::TextBlock),
            Body::TextBlock(TextBlockData {
                lines: vec![
                    "src (180)".into(),
                    "tests (30)".into(),
                    "docs (9)".into(),
                    "(root) (1)".into(),
                ]
            })
        );
    }

    #[test]
    fn entries_uses_dir_names_as_keys() {
        assert_eq!(
            render_body(fixed_scan(), Shape::Entries),
            Body::Entries(EntriesData {
                items: vec![
                    Entry {
                        key: "src".into(),
                        value: Some("180".into()),
                        status: None,
                    },
                    Entry {
                        key: "tests".into(),
                        value: Some("30".into()),
                        status: None,
                    },
                    Entry {
                        key: "docs".into(),
                        value: Some("9".into()),
                        status: None,
                    },
                    Entry {
                        key: ROOT_LABEL.into(),
                        value: Some("1".into()),
                        status: None,
                    },
                ],
            })
        );
    }

    #[test]
    fn bars_caps_top_level_entries() {
        let top: Vec<_> = (0..(TOP_LEVEL_LIMIT + 5))
            .map(|i| (format!("dir{i}"), 1))
            .collect();
        let scan = Scan {
            file_count: top.len(),
            top_level: top,
        };
        assert!(
            matches!(render_body(scan, Shape::Bars), Body::Bars(BarsData { ref bars }) if bars.len() == TOP_LEVEL_LIMIT)
        );
    }

    #[test]
    fn markdown_emphasises_dir_names_with_count_suffix() {
        assert_eq!(
            render_body(fixed_scan(), Shape::MarkdownTextBlock),
            Body::MarkdownTextBlock(MarkdownTextBlockData {
                value: "- **src** — 180 files\n- **tests** — 30 files\n- **docs** — 9 files\n- **(root)** — 1 file".into()
            })
        );
    }

    #[test]
    fn render_body_uses_text_fallback_and_empty_structural_shapes() {
        assert_eq!(
            render_body(fixed_scan(), Shape::Ratio),
            Body::Text(TextData {
                value: "220 files".into()
            })
        );
        assert_eq!(
            render_body(Scan::default(), Shape::Entries),
            Body::Entries(EntriesData { items: vec![] })
        );
        assert_eq!(
            render_body(Scan::default(), Shape::Bars),
            Body::Bars(BarsData { bars: vec![] })
        );
        assert_eq!(
            render_body(Scan::default(), Shape::MarkdownTextBlock),
            Body::MarkdownTextBlock(MarkdownTextBlockData {
                value: String::new()
            })
        );
    }

    #[test]
    fn empty_text_block_shape_returns_no_lines() {
        assert_eq!(
            render_body(Scan::default(), Shape::TextBlock),
            Body::TextBlock(TextBlockData { lines: vec![] })
        );
    }

    #[tokio::test]
    async fn fetch_scans_current_repo_for_multiple_shapes() {
        let text = CodeFiles.fetch(&ctx(None)).await.unwrap();
        assert!(
            matches!(text.body, Body::Text(TextData { ref value }) if value.ends_with("files") || value.ends_with("file"))
        );

        let entries = CodeFiles.fetch(&ctx(Some(Shape::Entries))).await.unwrap();
        assert!(
            matches!(entries.body, Body::Entries(EntriesData { ref items })
                if !items.is_empty()
                    && !items[0].key.is_empty()
                    && !items[0].value.as_deref().unwrap_or_default().is_empty())
        );

        let markdown = CodeFiles
            .fetch(&ctx(Some(Shape::MarkdownTextBlock)))
            .await
            .unwrap();
        assert!(
            matches!(markdown.body, Body::MarkdownTextBlock(MarkdownTextBlockData { ref value }) if value.contains("**"))
        );
    }
}
