//! Generate Markdown from path lists.
//!
//! This module normalizes paths, skips duplicates, and renders selected files into Markdown.

use std::{collections::HashSet, io::Write, path::Path};

use crate::{
    logger::Logger,
    normalizer::Normalizer,
    renderer::{RenderOptions, Renderer},
    util::path_display::display_path,
};

const DEFAULT_PROJECT_NAME: &str = "Project Outline";

/// Generates Markdown for `paths`.
///
/// Relative paths are resolved against `origin_base`. File headings use paths made relative to
/// `root`.
///
/// Duplicate files are skipped after normalization, while preserving the first occurrence order.
///
/// Rendering behavior such as file-size limits and placeholder output is controlled by
/// `render_options`.
pub fn generate_markdown_from_paths<W: Write>(
    paths: &[&Path],
    output: W,
    render_options: RenderOptions,
    root: &Path,
    origin_base: &Path,
    project_title: Option<&str>,
    logger: Logger,
) -> Result<(), Box<dyn std::error::Error>> {
    let normalizer = Normalizer::new(root, origin_base)?;

    let mut renderer = Renderer::new(output, render_options).with_logger(logger);
    let project_title = project_title.unwrap_or_else(|| derive_project_title(root));
    renderer.render_header(project_title)?;

    let mut seen_paths = HashSet::new();
    for path in paths {
        let normalized_path = normalizer.normalize(path)?;
        if !seen_paths.insert(normalized_path.clone()) {
            logger.warn(format!(
                "skipping duplicate file: {}",
                display_path(&normalized_path.root_relative)
            ));
            continue;
        }
        renderer.render_path(&normalized_path)?;
    }
    Ok(())
}

fn derive_project_title(root: &Path) -> &str {
    if let Some(os_str_name) = root.file_name()
        && let Some(name) = os_str_name.to_str()
    {
        name
    } else {
        DEFAULT_PROJECT_NAME
    }
}

#[cfg(test)]
mod tests {
    use std::ffi::OsStr;
    use std::fs;
    use std::io::Write;
    use std::os::unix::ffi::OsStrExt;
    use std::path::Path;

    use crate::logger::Logger;
    use crate::renderer::RenderOptions;
    use tempfile::tempdir;

    use super::{DEFAULT_PROJECT_NAME, derive_project_title, generate_markdown_from_paths};

    fn generate_with_defaults<W: Write>(
        paths: &[&Path],
        output: W,
        root: &Path,
        origin_base: &Path,
        project_title: Option<&str>,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let logger = Logger::default();
        let render_options = RenderOptions::default();
        generate_markdown_from_paths(
            paths,
            output,
            render_options,
            root,
            origin_base,
            project_title,
            logger,
        )
    }

    #[test]
    fn empty_path_list_produces_header_with_specified_project_title() {
        let temp_dir = tempdir().unwrap();
        let paths = [];
        let mut output = Vec::new();
        let root = temp_dir.path();
        let origin_base = temp_dir.path();

        generate_with_defaults(&paths, &mut output, root, origin_base, Some("Project name"))
            .unwrap();

        assert_eq!(String::from_utf8(output).unwrap(), "# Project name\n");
    }

    #[test]
    fn renders_single_file_from_path_list() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path();
        let paths = [Path::new("test_main.rs")];
        let mut output = Vec::new();
        let root = temp_dir.path();

        fs::write(origin_base.join("test_main.rs"), "fn main() {}").unwrap();

        generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap();

        let output_str = String::from_utf8(output).unwrap();

        assert!(output_str.contains("## File: test_main.rs"));
        assert!(output_str.contains("fn main() {}"));
    }

    #[test]
    fn renders_multiple_files_in_order() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path();
        let paths = [Path::new("a.rs"), Path::new("b.rs")];
        let mut output = Vec::new();
        let root = temp_dir.path();

        fs::write(origin_base.join("a.rs"), "A").unwrap();
        fs::write(origin_base.join("b.rs"), "B").unwrap();

        generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap();

        let output = String::from_utf8(output).unwrap();

        let a_pos = output.find("a.rs").unwrap();
        let b_pos = output.find("b.rs").unwrap();

        assert!(a_pos < b_pos);
    }

    #[test]
    fn normalizes_paths_before_rendering() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path();
        let paths = [Path::new("test/./main.rs")];
        let mut output = Vec::new();
        let root = temp_dir.path();

        let write_dir = temp_dir.path().join("test");
        fs::create_dir_all(&write_dir).unwrap();
        fs::write(write_dir.join("main.rs"), "fn main() {}").unwrap();

        generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap();

        let output = String::from_utf8(output).unwrap();

        assert!(output.contains("## File: test/main.rs"));
    }

    #[test]
    fn resolves_paths_from_origin_but_outputs_root_relative_paths() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path().join("sandbox/src");
        let paths = [Path::new("main.rs")];
        let mut output = Vec::new();
        let root = temp_dir.path().join("project");

        fs::create_dir_all(&origin_base).unwrap();
        fs::write(origin_base.join("main.rs"), "fn main() {}").unwrap();

        generate_with_defaults(&paths, &mut output, &root, &origin_base, None).unwrap();

        let output = String::from_utf8(output).unwrap();

        // Must contain file content → proves correct reading
        assert!(output.contains("fn main() {}"));

        // Must contain normalized path → proves normalization applied
        assert!(output.contains("sandbox/src/main.rs"));
    }

    #[test]
    fn absolute_paths_are_not_resolved_against_origin() {
        let temp_dir1 = tempdir().unwrap();
        let temp_dir2 = tempdir().unwrap();
        let origin_base = temp_dir2.path();
        let filepath = temp_dir1.path().join("test_main.rs");
        let paths = [filepath.as_ref()];
        let mut output = Vec::new();
        let root = temp_dir2.path();
        fs::write(&filepath, "fn main() {}").unwrap();

        generate_with_defaults(&paths, &mut output, root, origin_base, None).unwrap();

        let output = String::from_utf8(output).unwrap();

        // Must contain file content → proves correct reading
        assert!(output.contains("fn main() {}"));
    }

    #[test]
    fn duplicate_files_in_sequence_are_skipped() {
        let temp_dir = tempdir().unwrap();
        let origin = temp_dir.path();
        let root = temp_dir.path();

        fs::write(origin.join("a.rs"), "A").unwrap();

        let paths = [Path::new("a.rs"), Path::new("a.rs")];
        let mut output = Vec::new();

        generate_with_defaults(&paths, &mut output, root, origin, None).unwrap();

        let output = String::from_utf8(output).unwrap();

        assert_eq!(output.matches("## File: a.rs").count(), 1);
    }

    #[test]
    fn duplicate_files_are_skipped_with_preserved_display_order_even_if_not_adjacent() {
        let temp_dir = tempdir().unwrap();
        let origin = temp_dir.path();
        let root = temp_dir.path();

        fs::write(origin.join("a.rs"), "A").unwrap();
        fs::write(origin.join("b.rs"), "B").unwrap();

        let paths = [Path::new("a.rs"), Path::new("b.rs"), Path::new("a.rs")];
        let mut output = Vec::new();

        generate_with_defaults(&paths, &mut output, root, origin, None).unwrap();

        let output = String::from_utf8(output).unwrap();
        assert_eq!(output.matches("## File: a.rs").count(), 1);
        assert_eq!(output.matches("## File: b.rs").count(), 1);
        let a_pos = output.find("a.rs").unwrap();
        let b_pos = output.find("b.rs").unwrap();
        assert!(a_pos < b_pos);
    }

    #[test]
    fn lexically_equivalent_paths_are_detected_as_duplicates() {
        let temp_dir = tempdir().unwrap();
        let origin = temp_dir.path();
        let root = temp_dir.path();

        fs::create_dir_all(origin.join("bla")).unwrap();
        fs::write(origin.join("a.rs"), "A").unwrap();
        fs::write(origin.join("b.rs"), "B").unwrap();

        let paths = [
            Path::new("a.rs"),
            Path::new("b.rs"),
            Path::new("bla/../a.rs"),
        ];
        let mut output = Vec::new();

        generate_with_defaults(&paths, &mut output, root, origin, None).unwrap();

        let output = String::from_utf8(output).unwrap();
        assert_eq!(output.matches("## File: a.rs").count(), 1);
    }

    #[test]
    fn project_title_is_derived_from_root_by_default_even_if_directory_does_not_exist() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path();
        let paths = [];
        let mut output = Vec::new();
        let root = temp_dir.path().join("repo2markdown");

        generate_with_defaults(&paths, &mut output, &root, origin_base, None).unwrap();

        let output_str = String::from_utf8(output).unwrap();

        assert_eq!(output_str, "# repo2markdown\n");
    }

    #[test]
    fn project_title_fallsback_to_default_if_root_is_filesystem_root() {
        assert_eq!(derive_project_title(Path::new("/")), DEFAULT_PROJECT_NAME);
    }

    #[test]
    fn project_title_fallsback_if_root_ending_is_not_utf8() {
        let root = Path::new(OsStr::from_bytes(b"/root/fd\xC3"));
        assert_eq!(derive_project_title(root), DEFAULT_PROJECT_NAME);
    }

    #[test]
    fn deriving_project_title_from_root_ignores_trailing_slash() {
        let root = Path::new("/root/repo2markdown/");
        assert_eq!(derive_project_title(root), "repo2markdown");
    }

    #[test]
    fn generator_uses_render_options_for_large_file_placeholders() {
        let temp_dir = tempdir().unwrap();
        let origin_base = temp_dir.path();
        let root = temp_dir.path();

        let file_path = origin_base.join("big.txt");
        fs::write(&file_path, "A".repeat(10)).unwrap();

        let paths = [Path::new("big.txt")];
        let mut output = Vec::new();

        let config = RenderOptions {
            placeholder_for_binary_files: false,
            placeholder_for_large_files: true,
            max_file_size: 5,
        };

        generate_markdown_from_paths(
            &paths,
            &mut output,
            config,
            root,
            origin_base,
            None,
            Logger::default(),
        )
        .unwrap();

        let output = String::from_utf8(output).unwrap();
        assert!(output.contains("## File: big.txt"));
        assert!(output.contains("[FILE TOO LARGE]"));
    }
}
