//! Markdown rendering primitives.
//!
//! This module renders project headers and normalized file paths into Markdown.
//! Rendering behavior is controlled by [`RenderOptions`].

use std::{
    fs::File,
    io::{Read, Write},
    path::Path,
};

use crate::{
    logger::Logger,
    normalizer::NormalizedPath,
    util::{
        fence::generate_outer_backticks, language::detect_language, path_display::display_path,
    },
};

/// Default maximum file size, in bytes. Larger files are not included in Markdown output.
pub const DEFAULT_MAX_FILE_SIZE: u64 = 1_000_000;

/// Renders files and project metadata as Markdown.
#[derive(Debug)]
pub struct Renderer<'a, W: Write, LW: Write> {
    output: W,
    logger: &'a Logger<LW>,
    max_file_size: u64,
    placeholder_for_binary_files: bool,
    placeholder_for_large_files: bool,
}

impl<'a, W: Write, LW: Write> Renderer<'a, W, LW> {
    /// Creates a renderer with explicit rendering options.
    pub fn new(output: W, logger: &'a Logger<LW>, config: RenderOptions) -> Self {
        Self {
            output,
            logger,
            max_file_size: config.max_file_size,
            placeholder_for_binary_files: config.placeholder_for_binary_files,
            placeholder_for_large_files: config.placeholder_for_large_files,
        }
    }

    /// Sets the maximum file size, in bytes, that will be rendered.
    pub fn with_max_file_size(mut self, max_file_size: u64) -> Self {
        self.max_file_size = max_file_size;
        self
    }

    /// Controls whether skipped binary files are represented by placeholders.
    pub fn with_binary_file_placeholder(mut self, placeholder_for_binary_files: bool) -> Self {
        self.placeholder_for_binary_files = placeholder_for_binary_files;
        self
    }

    /// Controls whether skipped large files are represented by placeholders.
    pub fn with_large_file_placeholder(mut self, placeholder_for_large_files: bool) -> Self {
        self.placeholder_for_large_files = placeholder_for_large_files;
        self
    }

    /// Renders the top-level project heading.
    pub fn render_header(&mut self, project_title: &str) -> std::io::Result<()> {
        writeln!(self.output, "# {}", project_title)
    }

    /// Renders a normalized path.
    ///
    /// Text files are rendered as fenced Markdown code blocks. Large files and binary files are
    /// skipped unless their corresponding placeholder options are enabled.
    pub fn render_path(&mut self, normalized_path: &NormalizedPath) -> std::io::Result<()> {
        let metadata = std::fs::metadata(&normalized_path.absolute)?;
        if metadata.len() > self.max_file_size {
            self.warn_about_filesize(&normalized_path.root_relative, metadata.len());
            self.render_large_file(&normalized_path.root_relative)
        } else {
            let file = File::open(&normalized_path.absolute)?;
            self.render_file(&normalized_path.root_relative, file)
        }
    }

    fn render_file<R: Read>(&mut self, filename: &Path, mut reader: R) -> std::io::Result<()> {
        let mut bytes = Vec::new();
        reader.read_to_end(&mut bytes)?;
        let contents = if let Ok(utf8string) = std::str::from_utf8(&bytes) {
            utf8string
        } else {
            self.warn_about_binary_file(filename);
            return self.render_binary_file(filename);
        };
        self.write_file(filename, contents)
    }

    fn write_file(&mut self, filename: &Path, contents: &str) -> std::io::Result<()> {
        let name = display_path(filename);
        let fence = generate_outer_backticks(contents);
        let language = detect_language(filename, contents);
        self.log_file_render(filename);
        writeln!(self.output)?;
        writeln!(self.output, "## File: {}", name)?;
        writeln!(self.output, "{}{}", fence, language)?;
        writeln!(self.output, "{}", contents)?;
        writeln!(self.output, "{}", fence)
    }

    fn render_large_file(&mut self, filename: &Path) -> std::io::Result<()> {
        if !self.placeholder_for_large_files {
            return Ok(());
        }
        let name = display_path(filename);
        writeln!(self.output)?;
        writeln!(self.output, "## File: {}", name)?;
        writeln!(self.output, "[FILE TOO LARGE]")
    }

    fn render_binary_file(&mut self, filename: &Path) -> std::io::Result<()> {
        if !self.placeholder_for_binary_files {
            return Ok(());
        }
        let name = display_path(filename);
        writeln!(self.output)?;
        writeln!(self.output, "## File: {}", name)?;
        writeln!(self.output, "[BINARY FILE]")
    }

    fn warn_about_filesize(&self, filename: &Path, filesize: u64) {
        self.logger.warn(format!(
            "skipping large file: {} ({} > limit {})",
            display_path(filename),
            human_readable_size(filesize),
            human_readable_size(self.max_file_size),
        ))
    }

    fn warn_about_binary_file(&self, filename: &Path) {
        self.logger
            .warn(format!("skipping binary file: {}", display_path(filename)))
    }

    fn log_file_render(&self, filename: &Path) {
        self.logger
            .info(format!("rendering file: {}", display_path(filename)));
    }
}

/// Options controlling how files are rendered.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RenderOptions {
    /// Maximum file size, in bytes, to include in Markdown output.
    pub max_file_size: u64,
    /// Whether skipped binary files should be shown as `[BINARY FILE]`.
    pub placeholder_for_binary_files: bool,
    /// Whether skipped large files should be shown as `[FILE TOO LARGE]`.
    pub placeholder_for_large_files: bool,
}

impl Default for RenderOptions {
    fn default() -> Self {
        RenderOptions {
            max_file_size: DEFAULT_MAX_FILE_SIZE,
            placeholder_for_binary_files: false,
            placeholder_for_large_files: false,
        }
    }
}

fn human_readable_size(bytes: u64) -> String {
    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];

    let mut size = bytes as f64;
    let mut unit = 0;

    while size >= 1024.0 && unit < UNITS.len() - 1 {
        size /= 1024.0;
        unit += 1;
    }

    if unit == 0 {
        format!("{} {}", bytes, UNITS[unit])
    } else {
        format!("{:.1} {}", size, UNITS[unit])
    }
}

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

    use tempfile::tempdir;

    use crate::{
        logger::{Logger, Verbosity},
        normalizer::NormalizedPath,
        util::test_support::{logger_with_shared_log, quiet_logger},
    };

    use super::{RenderOptions, Renderer, human_readable_size};

    fn renderer_for_tests<'a, W: Write, LW: Write>(
        output: W,
        logger: &'a Logger<LW>,
    ) -> Renderer<'a, W, LW> {
        Renderer::new(output, logger, RenderOptions::default())
    }

    #[test]
    fn renderer_writes_header() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        renderer.render_header("Project name").unwrap();

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

    #[test]
    fn renderer_renders_single_rust_file_with_language_fence() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("fn main() {}");
        renderer.render_file(Path::new("main.rs"), input).unwrap();

        let expected = "\n## File: main.rs\n```rust\nfn main() {}\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn binary_files_are_not_included_in_output_at_all_by_default() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new(&[0x00, 0x01, 0x02, 0xc3]);
        renderer.render_file(Path::new("image.png"), input).unwrap();

        assert_eq!(String::from_utf8(output).unwrap(), "");
    }

    #[test]
    fn renderer_can_place_a_placeholder_for_binary_files() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer =
            renderer_for_tests(&mut output, &logger).with_binary_file_placeholder(true);

        let input = Cursor::new(&[0x00, 0x01, 0x02, 0xc3]);
        renderer.render_file(Path::new("image.png"), input).unwrap();

        let expected = "\n## File: image.png\n[BINARY FILE]\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn filename_with_linebreaks_and_invalid_chars_handled_properly() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("fn main() {}");
        let filename = Path::new(OsStr::from_bytes(b"jap\xE3\x81\x82dir/some\nma\xc3in.rs"));
        renderer.render_file(filename, input).unwrap();

        let expected = "\n## File: japあdir/some\\nma\\xC3in.rs\n```rust\nfn main() {}\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn file_with_backticks_is_handled_safely() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("fn main() { println!(\"``` inside\"); }");
        renderer
            .render_file(Path::new("example.rs"), input)
            .unwrap();

        let expected =
            "\n## File: example.rs\n````rust\nfn main() { println!(\"``` inside\"); }\n````\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn unknown_filetype_has_no_language_fence() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("hello world");
        renderer.render_file(Path::new("readme"), input).unwrap();

        let expected = "\n## File: readme\n```\nhello world\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn txt_file_has_no_language_fence() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("hello world");
        renderer.render_file(Path::new("hello.txt"), input).unwrap();

        let expected = "\n## File: hello.txt\n```\nhello world\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn python_file_has_python_language_fence() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("print('hello')");
        renderer.render_file(Path::new("main.py"), input).unwrap();

        let expected = "\n## File: main.py\n```python\nprint('hello')\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn json_file_has_json_language_fence() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("{\"key\":\"value\"}");
        renderer.render_file(Path::new("main.json"), input).unwrap();

        let expected = "\n## File: main.json\n```json\n{\"key\":\"value\"}\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn uppercase_extension_is_still_detected() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("fn main() {}");
        renderer.render_file(Path::new("MAIN.RS"), input).unwrap();

        let expected = "\n## File: MAIN.RS\n```rust\nfn main() {}\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn bash_file_detected_from_shebang() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("#! /bin/bash\necho hello");
        renderer.render_file(Path::new("hello"), input).unwrap();

        let expected = "\n## File: hello\n```bash\n#! /bin/bash\necho hello\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn python_file_detected_from_shebang() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger);

        let input = Cursor::new("#! /bin/python3\nprint('hello')");
        renderer.render_file(Path::new("hello"), input).unwrap();

        let expected = "\n## File: hello\n```python\n#! /bin/python3\nprint('hello')\n```\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn large_files_are_not_included_in_output_at_all_by_default() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5);

        let temp_dir = tempdir().unwrap();
        let file_path = temp_dir.path().join("big.txt");

        std::fs::write(&file_path, "A".repeat(10)).unwrap();

        let normalized_path = NormalizedPath {
            root_relative: PathBuf::from("big.txt"),
            absolute: file_path,
        };

        renderer.render_path(&normalized_path).unwrap();

        assert_eq!(String::from_utf8(output).unwrap(), "");
    }

    #[test]
    fn renderer_can_place_a_placeholder_for_large_files() {
        let mut output = Vec::new();
        let logger = quiet_logger();
        let mut renderer = renderer_for_tests(&mut output, &logger)
            .with_large_file_placeholder(true)
            .with_max_file_size(5);

        let temp_dir = tempdir().unwrap();
        let file_path = temp_dir.path().join("big.txt");

        std::fs::write(&file_path, "A".repeat(10)).unwrap();

        let normalized_path = NormalizedPath {
            root_relative: PathBuf::from("big.txt"),
            absolute: file_path,
        };

        renderer.render_path(&normalized_path).unwrap();

        let expected = "\n## File: big.txt\n[FILE TOO LARGE]\n";
        assert_eq!(String::from_utf8(output).unwrap(), expected);
    }

    #[test]
    fn format_readable_filesizes() {
        assert_eq!(human_readable_size(10), "10 B");
        assert_eq!(human_readable_size(1500), "1.5 KiB");
        assert_eq!(human_readable_size(1_048_576), "1.0 MiB");
        assert_eq!(human_readable_size(5_242_880), "5.0 MiB");
    }

    #[test]
    fn verbose_verbosity_logs_rendered_text_files() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Verbose);
        let mut renderer = renderer_for_tests(&mut output, &logger);

        renderer
            .render_file(Path::new("main.rs"), Cursor::new("fn main() {}"))
            .unwrap();

        assert_eq!(logs.contents(), "Info: rendering file: main.rs\n");
    }

    #[test]
    fn normal_verbosity_does_not_log_rendered_text_files() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Normal);
        let mut renderer = renderer_for_tests(&mut output, &logger);

        renderer
            .render_file(Path::new("main.rs"), Cursor::new("fn main() {}"))
            .unwrap();

        assert_eq!(logs.contents(), "");
    }

    #[test]
    fn normal_verbosity_logs_skipped_binary_files() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Normal);
        let mut renderer = renderer_for_tests(&mut output, &logger);

        renderer
            .render_file(
                Path::new("image.png"),
                Cursor::new(&[0x00, 0x01, 0x02, 0xc3]),
            )
            .unwrap();

        assert_eq!(
            logs.contents(),
            "Warning: skipping binary file: image.png\n"
        );
    }

    #[test]
    fn quiet_verbosity_suppresses_skipped_binary_file_warning() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Quiet);
        let mut renderer = renderer_for_tests(&mut output, &logger);

        renderer
            .render_file(
                Path::new("image.png"),
                Cursor::new(&[0x00, 0x01, 0x02, 0xc3]),
            )
            .unwrap();

        assert_eq!(logs.contents(), "");
    }

    #[test]
    fn normal_verbosity_logs_skipped_large_files() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Normal);
        let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5);

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

        let normalized_path = NormalizedPath {
            root_relative: PathBuf::from("big.txt"),
            absolute: file_path,
        };

        renderer.render_path(&normalized_path).unwrap();

        assert_eq!(
            logs.contents(),
            "Warning: skipping large file: big.txt (10 B > limit 5 B)\n"
        );
    }

    #[test]
    fn quiet_verbosity_suppresses_skipped_large_file_warning() {
        let mut output = Vec::new();
        let (logger, logs) = logger_with_shared_log(Verbosity::Quiet);
        let mut renderer = renderer_for_tests(&mut output, &logger).with_max_file_size(5);

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

        let normalized_path = NormalizedPath {
            root_relative: PathBuf::from("big.txt"),
            absolute: file_path,
        };

        renderer.render_path(&normalized_path).unwrap();

        assert_eq!(logs.contents(), "");
    }
}
