mod big_text;
mod config;
mod cursor;
mod debug;
mod document;
mod error;
mod keybindings;
mod links;
mod model;
mod renderer;
mod setup;
mod sources;
mod view;
mod watch;
mod worker;

#[cfg(not(windows))]
use std::os::fd::IntoRawFd as _;

use std::{
    fmt::Display,
    io::{self, Read as _},
    path::PathBuf,
    sync::{
        Arc, OnceLock, RwLock,
        mpsc::{self},
    },
};

use clap::{ArgMatches, arg, command, value_parser};
use ratatui::{
    Terminal,
    crossterm::{
        event::{DisableMouseCapture, EnableMouseCapture},
        tty::IsTty as _,
    },
    layout::Size,
    prelude::CrosstermBackend,
};

use mdfrier::MarkdownLink;
use ratatui_image::{picker::ProtocolType, protocol::Protocol, sliced::SlicedProtocol};
use setup::{SetupResult, setup_graphics};

use crate::{
    config::Config,
    document::{Section, SectionID},
    error::Error,
    model::{DocumentId, Model},
    renderer::run_loop,
    sources::{BuiltIn, DocumentSource, SharedDocumentSource, open_source},
    watch::watch,
    worker::{ImageCache, worker_thread},
};

pub const OK_END: &str = " ok.";

pub static VERSION: OnceLock<String> = OnceLock::new();

fn main() -> io::Result<()> {
    let mut cmd = command!() // requires `cargo` feature
        .arg(
            arg!([SOURCE] "The markdown source.\nCan be a file path, a URL, a github repo in \"github:[owner]/[repo]\" format, or '-' or omit, for stdin.")
        )
        .arg(arg!(-d --"deep-fry" "Extra deep fried images.").value_parser(value_parser!(bool)))
        .arg(arg!(-w --"watch" "Watch markdown file, reload on changes.").value_parser(value_parser!(bool)))
        .arg(arg!(-s --"setup" "Force font setup (again).").value_parser(value_parser!(bool)))
        .arg(
            arg!(--"print-config" "Write out a mostly complete config file example to stdout.")
                .value_parser(value_parser!(bool)),
        )
        .arg(
            arg!(--"no-cap-checks" "Do not query the terminal stdin for capabilities.")
                .value_parser(value_parser!(bool)),
        )
        .arg(arg!(--"debug-override-protocol-type" <PROTOCOL> "Force graphics protocol to a specific type."))
        .arg(
            arg!(--log [FILE] "Log to a file with RUST_LOG, or stderr if omitted with RUST_LOG=debug.\nStderr should always be redirected, e.g. 2>/dev/pts/<tty> to pipe into another terminal.")
                .num_args(0..=1)
                .default_missing_value("")
                .value_parser(value_parser!(String)),
        )
        .arg(arg!(--"animate" "Animate scrolling on startup (for demo recordings).").hide(true).value_parser(value_parser!(bool)))
        ;
    let matches = cmd.get_matches_mut();

    if let Some(version) = cmd.get_version() {
        #[expect(unused_must_use)]
        VERSION.set(version.to_owned());
    }

    match main_with_args(&matches) {
        Err(Error::Usage(msg)) => {
            if let Some(msg) = msg {
                println!("Usage error: {msg}");
                println!();
            }
            cmd.write_help(&mut io::stdout())?;
        }
        Err(Error::UserAbort(msg)) => {
            println!("Abort: {msg}");
        }
        Err(err) => eprintln!("{err}"),
        _ => {}
    }
    Ok(())
}

#[expect(clippy::too_many_lines)]
fn main_with_args(matches: &ArgMatches) -> Result<(), Error> {
    let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
        .panic_section(format!(
            "This is a bug. Consider reporting it at {}",
            env!("CARGO_PKG_REPOSITORY")
        ))
        .display_location_section(true)
        .display_env_section(true)
        .into_hooks();
    eyre_hook.install()?;
    std::panic::set_hook(Box::new(move |panic_info| {
        if let Err(err) = crossterm::terminal::disable_raw_mode() {
            eprintln!("Unable to disable raw mode: {:?}", err);
        }
        let msg = format!("{}", panic_hook.panic_report(panic_info));
        log::error!("Panic: {}", msg);
        eprintln!("{msg}");
        #[expect(clippy::exit)]
        std::process::exit(libc::EXIT_FAILURE);
    }));

    if *matches.get_one("print-config").unwrap_or(&false) {
        config::print_default()?;
        return Ok(());
    }

    let log = matches.get_one::<String>("log");
    debug::init_logger(debug::LogTarget::from(log))?;

    let source: Option<String> = matches.get_one::<String>("SOURCE").cloned();

    let mut user_config = config::load_or_ask()?;
    let mut config = Config::from(user_config.clone());

    let (text, document_source) = match source {
        Some(source) if source == "-" => {
            let mut text = String::new();
            print!("Reading stdin...");
            io::stdin().read_to_string(&mut text)?;
            println!("{OK_END}");
            (text, DocumentSource::Stdin { text: None })
        }
        None => {
            if io::stdin().is_tty() {
                (String::new(), DocumentSource::BuiltIn(BuiltIn::Welcome))
            } else {
                let mut text = String::new();
                print!("Reading stdin...");
                io::stdin().read_to_string(&mut text)?;
                println!("{OK_END}");
                (text, DocumentSource::Stdin { text: None })
            }
        }
        Some(source) => open_source(&source, config.url_transform_command.clone())?,
    };

    if text.is_empty()
        && !matches!(
            document_source,
            DocumentSource::BuiltIn(BuiltIn::Welcome)
                | DocumentSource::Image { .. }
                | DocumentSource::Pdf { .. }
        )
    {
        return Err(Error::Usage(Some("no input or empty")));
    }

    #[cfg(not(windows))]
    if !io::stdin().is_tty() {
        print!("Setting stdin to /dev/tty...");
        // Close the current stdin so that ratatui-image can read stuff from tty stdin.
        // SAFETY:
        // Calls some libc, not sure if this could be done otherwise.
        unsafe {
            // Attempt to open /dev/tty which will give us a new stdin
            let tty = std::fs::File::open("/dev/tty")?;

            // Get the file descriptor for /dev/tty
            let tty_fd = tty.into_raw_fd();

            // Duplicate the tty file descriptor to stdin (file descriptor 0)
            libc::dup2(tty_fd, libc::STDIN_FILENO);

            // Close the original tty file descriptor
            libc::close(tty_fd);
        }
        println!("{OK_END}");
    }

    let force_setup = *matches.get_one("setup").unwrap_or(&false);
    let no_cap_checks = *matches.get_one("no-cap-checks").unwrap_or(&false);
    let debug_override_protocol_type = config.debug_override_protocol_type.or(matches
        .get_one::<String>("debug-override-protocol-type")
        .map(|s| match s.as_str() {
            "Sixel" => ProtocolType::Sixel,
            "Iterm2" => ProtocolType::Iterm2,
            "Kitty" => ProtocolType::Kitty,
            _ => ProtocolType::Halfblocks,
        }));

    crossterm::terminal::enable_raw_mode()?;

    let (picker, renderer, has_text_size_protocol) = {
        let setup_result = setup_graphics(
            &mut user_config,
            force_setup,
            no_cap_checks,
            debug_override_protocol_type,
        );
        match setup_result {
            Ok(result) => match result {
                SetupResult::Aborted => return Err(Error::UserAbort("cancelled setup")),
                SetupResult::TextSizing(picker) => (picker, None, true),
                SetupResult::AsciiArt(picker) => (picker, None, false),
                SetupResult::Complete(picker, renderer) => (picker, Some(renderer), false),
            },
            Err(err) => return Err(err),
        }
    };

    let deep_fry = *matches.get_one("deep-fry").unwrap_or(&false);

    let watchmode_path = if *matches.get_one("watch").unwrap_or(&false)
        && let DocumentSource::File { path, .. } = &document_source
    {
        Some(path.clone())
    } else {
        None
    };

    let document_source = SharedDocumentSource(Arc::new(RwLock::new(document_source)));

    let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
    let (event_tx, event_rx) = mpsc::channel::<Event>();
    let watch_event_tx = event_tx.clone();

    #[cfg(not(windows))]
    if *matches.get_one("animate").unwrap_or(&false) {
        log::warn!("--animate");
        debug::animate_recording(event_tx.clone());
    }

    let config_max_image_height = config.max_image_height;
    config.theme.has_text_size_protocol = Some(has_text_size_protocol);
    let worker_config = config.clone();
    let worker_thread = worker_thread(
        document_source.clone(),
        picker,
        renderer,
        worker_config,
        deep_fry,
        cmd_rx,
        event_tx,
        config_max_image_height,
    );

    let backend = CrosstermBackend::new(io::stdout());
    let mut terminal = Terminal::new(backend)?;
    let enable_mouse_capture = config.enable_mouse_capture;
    if enable_mouse_capture {
        crossterm::execute!(io::stderr(), EnableMouseCapture)?;
    }
    let watch_debounce_milliseconds = config.watch_debounce_milliseconds;
    terminal.clear()?;

    if document_source.read()? == DocumentSource::BuiltIn(BuiltIn::Welcome) {
        cmd_tx.send(Cmd::LoadImage(None))?;
    }
    let model = Model::new(document_source, cmd_tx, event_rx, terminal.size()?, config);
    model.open(text)?;

    let debouncer = if let Some(path) = watchmode_path {
        log::info!("watching file");
        Some(watch(&path, watch_event_tx, watch_debounce_milliseconds)?)
    } else {
        drop(watch_event_tx);
        None
    };

    if let Err(err) = run_loop(terminal, model) {
        eprintln!("Runtime error: {err}");
    };
    drop(debouncer);

    if enable_mouse_capture {
        crossterm::execute!(io::stderr(), DisableMouseCapture)?;
    }
    crossterm::terminal::disable_raw_mode()?;

    match worker_thread.join() {
        Err(e) => eprintln!("Worker thread panic: {e:?}"),
        Ok(Err(Error::ThreadClosed)) => {
            log::debug!("worker_thread channel closed");
        }
        Ok(Err(e)) => eprintln!("Worker thread error: {e}"),
        _ => {}
    }
    Ok(())
}

pub enum Cmd {
    Parse(DocumentId, u16, String, Option<ImageCache>),
    OpenUrl(String),
    LoadImage(Option<(PathBuf, Size)>), // TODO: either included welcome logo, or a path, make an enum?
    LoadPdf(PathBuf, Size),
}

impl std::fmt::Debug for Cmd {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        Display::fmt(self, f)
    }
}

impl Display for Cmd {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Cmd::Parse(reload_id, width, _, cache) => {
                write!(
                    f,
                    "Cmd::Parse({reload_id:?}, {width}, <text>, cache={cache:?})",
                )
            }
            Cmd::OpenUrl(url) => write!(f, "Cmd::Open({url})"),
            Cmd::LoadImage(image) => write!(f, "Cmd::LoadImage({image:?})"),
            Cmd::LoadPdf(path, size) => write!(f, "Cmd::LoadPdf({path:?}, {size:?})"),
        }
    }
}

pub enum Event {
    NewDocument(DocumentId),
    ParseDone(DocumentId, Option<SectionID>, String), // Only signals "parsing done", not "images ready"!
    Parsed(DocumentId, Section),
    ImageLoaded(
        DocumentId,
        SectionID,
        MarkdownLink,
        (SlicedProtocol, Size, Size),
        bool,
    ),
    ImageFailed(DocumentId, SectionID, String, String),
    HeaderLoaded(DocumentId, SectionID, Vec<(String, u8, Protocol)>),
    RootImageLoaded(Protocol), // Not markdown related, e.g. the welcome logo image.
    PdfPageLoaded(usize, SlicedProtocol),
    FileChanged,
    Scroll(i16),
    NewSourceContent(String),
    ReferenceDefinition {
        id: String,
        url: String,
    },
    CodeLoaded(DocumentId, usize, ratatui::prelude::Text<'static>),
    WorkerError(Error),
}

impl Display for Event {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Event::NewDocument(document_id) => write!(f, "Event::NewDocument({document_id})"),
            Event::ParseDone(document_id, last_section_id, _text) => {
                write!(f, "Event::ParseDone({document_id}, {last_section_id:?})")
            }
            Event::Parsed(document_id, section) => {
                write!(
                    f,
                    "Event::Parsed({document_id}, id:{}, content: {})",
                    section.id, section.content
                )
            }
            Event::ImageLoaded(document_id, section_id, url, _, trailing_blank) => {
                write!(
                    f,
                    "Event::ImageLoaded({document_id}, {section_id}, {url}, {trailing_blank})"
                )
            }
            Event::ImageFailed(document_id, section_id, url, error) => {
                write!(
                    f,
                    "Event::ImageFailed({document_id}, {section_id}, {url}, {error})"
                )
            }
            Event::HeaderLoaded(document_id, section_id, rows) => {
                write!(
                    f,
                    "Event::HeaderLoaded({document_id}, {section_id}, {})",
                    rows.first()
                        .map(|(text, _, _)| text.clone())
                        .unwrap_or_default()
                )
            }
            Event::CodeLoaded(document_id, section_id, text) => {
                write!(
                    f,
                    "Event::CodeLoaded({document_id}, {section_id}, {}...)",
                    text.to_string().chars().take(10).collect::<String>()
                )
            }
            Event::ReferenceDefinition { id, url } => {
                write!(f, "Event::ReferenceDefinition {{ id: {id}, url: {url} }}")
            }
            Event::RootImageLoaded(_) => write!(f, "Event::RootImageLoaded"),
            Event::PdfPageLoaded(idx, _) => write!(f, "Event::PdfPageLoaded({idx})"),
            Event::FileChanged => write!(f, "Event::FileChanged"),
            Event::Scroll(s) => write!(f, "Event::Scroll({s})"),
            Event::NewSourceContent(_) => write!(f, "Event::NewSource"),
            Event::WorkerError(err) => write!(f, "Event::WorkerError({err})"),
        }
    }
}

impl std::fmt::Debug for Event {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Reuse Display impl
        Display::fmt(self, f)
    }
}

#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
    use std::{sync::mpsc, thread::JoinHandle};

    #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
    use insta::assert_snapshot;
    use ratatui::{Terminal, backend::TestBackend, layout::Size, text::Line};
    use ratatui_image::picker::{Picker, ProtocolType};

    use crate::{
        Cmd, Event,
        config::{Config, UserConfig},
        document::{Section, SectionContent},
        error::Error,
        model::Model,
        sources::SharedDocumentSource,
        view::view,
        worker::worker_thread,
    };

    #[ctor::ctor]
    fn init_logger() {
        crate::debug::init_test_logger();
    }

    fn setup(config: Config) -> (Model, JoinHandle<Result<(), Error>>, Terminal<TestBackend>) {
        let (cmd_tx, cmd_rx) = mpsc::channel::<Cmd>();
        let (event_tx, event_rx) = mpsc::channel::<Event>();

        let picker = Picker::halfblocks();
        assert_eq!(picker.protocol_type(), ProtocolType::Halfblocks);
        let mut worker_config = config.clone();
        worker_config.theme.has_text_size_protocol = Some(true);
        let document_source = SharedDocumentSource::test();
        let worker = worker_thread(
            document_source.clone(),
            picker,
            None,
            worker_config,
            false,
            cmd_rx,
            event_tx,
            config.max_image_height,
        );

        let screen_size = (80, 20).into();

        let model = Model::new(document_source, cmd_tx, event_rx, screen_size, config);

        let terminal =
            Terminal::new(TestBackend::new(screen_size.width, screen_size.height)).unwrap();

        (model, worker, terminal)
    }

    // Drop model so that cmd_rx gets closed and worker exits, then exit/join worker.
    fn teardown(model: Model, worker: JoinHandle<Result<(), Error>>) {
        drop(model);
        worker.join().unwrap().unwrap();
    }

    // Poll until parsed and no pending images.
    #[track_caller]
    fn poll_parsed(model: &mut Model) {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
        loop {
            let (_, parse_done, _) = model.process_events().unwrap();
            if parse_done {
                break;
            }
            assert!(
                std::time::Instant::now() < deadline,
                "timed out waiting for process_events to be done"
            );
            std::thread::sleep(std::time::Duration::from_millis(1));
        }
        log::debug!("poll_parsed completed");
    }

    // Poll until parsed and no pending images.
    fn poll_images_done(model: &mut Model) {
        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1);
        while model.has_pending_images() {
            model.process_events().unwrap();
            assert!(
                std::time::Instant::now() < deadline,
                "timed out waiting for has_pending_images to be done"
            );
            std::thread::sleep(std::time::Duration::from_millis(1));
        }
        log::debug!("poll_done completed");
    }

    #[test]
    fn parse() {
        let config = UserConfig {
            max_image_height: Some(10),
            ..Default::default()
        }
        .into();
        let (mut model, worker, mut terminal) = setup(config);

        model
            .open(String::from(
                r#"# Hello
This is a *test* markdown document.
Another line of same paragraph.

![image](./assets/NixOS.png)

# Another header
Some text

# Last bit
Goodbye."#,
            ))
            .unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("first parse image previews", terminal.backend());
        // Must load an image.
        poll_images_done(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("first parse done", terminal.backend());

        teardown(model, worker);
    }

    #[test]
    fn reload_move_image() {
        let config = UserConfig {
            max_image_height: Some(10),
            ..Default::default()
        }
        .into();
        let (mut model, worker, mut terminal) = setup(config);

        model
            .open(String::from(
                r#"# Hello
This is a test markdown document.

![image](./assets/NixOS.png)

Goodbye."#,
            ))
            .unwrap();
        poll_parsed(&mut model);
        poll_images_done(&mut model);

        model
            .reparse(
                String::from(
                    r#"# Hello

![image](./assets/NixOS.png)

This is a test markdown document.

Goodbye."#,
                ),
                model.screen_size.width,
            )
            .unwrap();
        poll_parsed(&mut model);
        log::debug!("poll_parsed before failing done");
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("reload move image up", terminal.backend());

        model
            .reparse(
                String::from(
                    r#"# Hello
This is a test markdown document.

![image](./assets/NixOS.png)

Goodbye."#,
                ),
                model.screen_size.width,
            )
            .unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("reload move image down", terminal.backend());

        teardown(model, worker);
    }

    #[test]
    fn reload_add_image() {
        let config = UserConfig {
            max_image_height: Some(10),
            ..Default::default()
        }
        .into();
        let (mut model, worker, mut terminal) = setup(config);

        model
            .open(String::from(
                r#"# Hello
This is a test markdown document.

![image](./assets/NixOS.png)

Goodbye."#,
            ))
            .unwrap();
        poll_parsed(&mut model);
        poll_images_done(&mut model);

        model
            .reparse(
                String::from(
                    r#"# Hello
This is a test markdown document.

![image](./assets/NixOS.png)

![image](./assets/you_fried.png)

Goodbye."#,
                ),
                model.screen_size.width,
            )
            .unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("reload add image preview", terminal.backend());
        // Must load an image.
        poll_images_done(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("reload add image done", terminal.backend());
        teardown(model, worker);
    }

    #[test]
    fn duplicate_image() {
        let config = UserConfig {
            max_image_height: Some(8),
            ..Default::default()
        }
        .into();
        let (mut model, worker, mut terminal) = setup(config);

        model
            .open(String::from(
                r#"# Hello

![image](./assets/NixOS.png)

Goodbye."#,
            ))
            .unwrap();
        poll_parsed(&mut model);
        poll_images_done(&mut model);

        model
            .reparse(
                String::from(
                    r#"# Hello

![image A](./assets/NixOS.png)

Goodbye.  

![image B](./assets/NixOS.png)"#,
                ),
                model.screen_size.width,
            )
            .unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("duplicate image preview", terminal.backend());
        // Must load an image.
        poll_images_done(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();
        #[cfg(not(any(target_os = "macos", target_arch = "aarch64")))]
        assert_snapshot!("duplicate image done", terminal.backend());
        teardown(model, worker);
    }

    #[test]
    fn simple_resize() {
        let config = UserConfig {
            max_image_height: Some(10),
            ..Default::default()
        }
        .into();
        let (mut model, worker, mut terminal) = setup(config);
        model.screen_size = Size::new(40, 20);

        model
            .open(String::from(
                r#"# Header here hee hee heeeeeeeeeeeeee
Line that should be broken up later
"#,
            ))
            .unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();

        let sections: Vec<&Section> = model.sections().collect();
        assert_eq!(3, sections.len());
        assert_eq!(
            SectionContent::Header("Header here hee hee".to_owned(), 1, None),
            sections[0].content
        );
        assert_eq!(
            SectionContent::Header("heeeeeeeeeeeeee".to_owned(), 1, None),
            sections[1].content
        );
        assert_eq!(
            SectionContent::Lines(vec![(
                Line::from("Line that should be broken up later"),
                Vec::new()
            ),]),
            sections[2].content
        );

        model.reload(Size::new(20, 20)).unwrap();
        poll_parsed(&mut model);
        terminal
            .draw(|frame| {
                view(&model, frame.buffer_mut());
            })
            .unwrap();

        let sections: Vec<&Section> = model.sections().collect();
        assert_eq!(6, sections.len());
        assert_eq!(
            SectionContent::Header("Header".to_owned(), 1, None),
            sections[0].content
        );
        assert_eq!(
            SectionContent::Header("here".to_owned(), 1, None),
            sections[1].content
        );
        assert_eq!(
            SectionContent::Header("hee hee".to_owned(), 1, None),
            sections[2].content
        );
        assert_eq!(
            SectionContent::Header("heeeeeeeee".to_owned(), 1, None),
            sections[3].content
        );
        assert_eq!(
            SectionContent::Header("eeeee".to_owned(), 1, None),
            sections[4].content
        );
        assert_eq!(
            SectionContent::Lines(vec![
                (Line::from("Line that should be"), Vec::new()),
                (Line::from("broken up later"), Vec::new()),
            ]),
            sections[5].content
        );

        teardown(model, worker);
    }
}
