/// Diff mode implementation.
///
/// This module provides functionality for comparing two items and displaying their
/// differences using external diff tools. Decompressed content is streamed to diff
/// via pipes and /dev/fd file descriptors — no temporary files are created.
use crate::config;
use crate::services::compression_service::CompressionService;
use crate::services::item_service::ItemService;
use anyhow::{Context, Result};
use clap::Command;
use command_fds::{CommandFdExt, FdMapping};
use log::debug;
use nix::fcntl::OFlag;
use nix::unistd::pipe2;
use std::io::Read;
use std::os::unix::io::{AsRawFd, OwnedFd};

fn validate_diff_args(_cmd: &mut Command, ids: &[i64], tags: &[String]) -> anyhow::Result<()> {
    if !tags.is_empty() {
        return Err(anyhow::anyhow!(
            "Tags are not supported with --diff. Please provide exactly two IDs."
        ));
    }
    if ids.len() != 2 {
        return Err(anyhow::anyhow!(
            "You must supply exactly two IDs when using --diff."
        ));
    }
    Ok(())
}

/// Fetches and validates items from the database for diff operation.
fn fetch_and_validate_items(
    conn: &mut rusqlite::Connection,
    ids: &[i64],
    item_service: &ItemService,
) -> Result<(
    crate::services::types::ItemWithMeta,
    crate::services::types::ItemWithMeta,
)> {
    let item_a = item_service
        .get_item(conn, ids[0])
        .with_context(|| format!("Unable to find first item (ID: {}) in database", ids[0]))?;
    let item_b = item_service
        .get_item(conn, ids[1])
        .with_context(|| format!("Unable to find second item (ID: {}) in database", ids[1]))?;

    debug!("DIFF: Found item A {:?}", item_a.item);
    debug!("DIFF: Found item B {:?}", item_b.item);

    Ok((item_a, item_b))
}

pub fn mode_diff(
    cmd: &mut Command,
    args: &crate::args::Args,
    conn: &mut rusqlite::Connection,
) -> anyhow::Result<()> {
    let ids: Vec<i64> = args
        .ids_or_tags
        .iter()
        .filter_map(|x| {
            if let crate::args::NumberOrString::Number(n) = x {
                Some(*n)
            } else {
                None
            }
        })
        .collect();

    let tags: Vec<String> = args
        .ids_or_tags
        .iter()
        .filter_map(|x| {
            if let crate::args::NumberOrString::Str(s) = x {
                Some(s.clone())
            } else {
                None
            }
        })
        .collect();

    validate_diff_args(cmd, &ids, &tags)?;

    let settings = config::Settings::new(args, config::Settings::default_dir()?)?;
    let item_service = ItemService::new(settings.dir.clone());
    let (item_a, item_b) = fetch_and_validate_items(conn, &ids, &item_service)?;

    run_external_diff(&item_service, &item_a, &item_b)
}

/// Creates a pipe with CLOEXEC set atomically, returns (read_fd, write_fd).
fn create_pipe() -> Result<(OwnedFd, OwnedFd)> {
    pipe2(OFlag::O_CLOEXEC).context("Failed to create pipe")
}

/// Streams decompressed item content through a pipe fd.
///
/// Returns a JoinHandle for the writer thread. The thread writes decompressed
/// data to write_fd and closes it when done (causing EOF for the reader).
fn spawn_writer_thread(
    item_service: &ItemService,
    item: &crate::services::types::ItemWithMeta,
    write_fd: OwnedFd,
) -> std::thread::JoinHandle<Result<()>> {
    let data_path = item_service.get_data_path().clone();
    let item_id = item.item.id.expect("item must have ID");
    let compression = item.item.compression.clone();
    let mut item_path = data_path;
    item_path.push(item_id.to_string());

    std::thread::spawn(move || -> Result<()> {
        let compression_service = CompressionService::new();
        let mut reader = compression_service
            .stream_item_content(item_path, &compression)
            .map_err(|e| anyhow::anyhow!("Failed to stream item {item_id}: {e}"))?;

        // Convert OwnedFd to File — safe, takes ownership, closes on drop
        let mut writer = std::fs::File::from(write_fd);
        crate::common::stream_copy(&mut reader, |chunk| {
            use std::io::Write;
            writer.write_all(chunk)
        })
        .map_err(|e| anyhow::anyhow!("Error reading item {item_id}: {e}"))?;
        // writer dropped here, closing write_fd → diff sees EOF
        Ok(())
    })
}

/// Runs external diff command, streaming decompressed content via /dev/fd pipes.
///
/// Creates two pipes, spawns writer threads to decompress each item into its pipe,
/// and runs `diff -u /dev/fd/N /dev/fd/M` where N and M are the pipe read fds.
/// The `command-fds` crate handles CLOEXEC clearing safely — no unsafe needed.
fn run_external_diff(
    item_service: &ItemService,
    item_a: &crate::services::types::ItemWithMeta,
    item_b: &crate::services::types::ItemWithMeta,
) -> Result<()> {
    if which::which_global("diff").is_err() {
        return Err(anyhow::anyhow!(
            "diff command not found. Please install diffutils."
        ));
    }

    let (read_fd_a, write_fd_a) = create_pipe()?;
    let (read_fd_b, write_fd_b) = create_pipe()?;

    // Spawn writer threads — they take ownership of write fds and close them on exit
    let writer_a = spawn_writer_thread(item_service, item_a, write_fd_a);
    let writer_b = spawn_writer_thread(item_service, item_b, write_fd_b);

    // Get fd numbers for /dev/fd paths (borrows, does not consume)
    let raw_read_a = read_fd_a.as_raw_fd();
    let raw_read_b = read_fd_b.as_raw_fd();

    debug!("DIFF: pipe fds: a(r={raw_read_a}) b(r={raw_read_b})");

    // Spawn diff with /dev/fd/N paths. command-fds handles CLOEXEC clearing
    // and fd inheritance safely — the fds are released from OwnedFd to the
    // child process. If spawn fails, the OwnedFd values in FdMapping are
    // dropped and the fds are properly closed.
    let mut command = std::process::Command::new("diff");
    command
        .arg("-u")
        .arg(format!("/dev/fd/{raw_read_a}"))
        .arg(format!("/dev/fd/{raw_read_b}"))
        .stdout(std::process::Stdio::inherit())
        .stderr(std::process::Stdio::inherit())
        .stdin(std::process::Stdio::null())
        .fd_mappings(vec![
            FdMapping {
                parent_fd: read_fd_a,
                child_fd: raw_read_a,
            },
            FdMapping {
                parent_fd: read_fd_b,
                child_fd: raw_read_b,
            },
        ])
        .map_err(|e| anyhow::anyhow!("FD mapping collision: {e}"))?;
    let mut child = command.spawn().context("Failed to spawn diff command")?;

    let status = child.wait().context("Failed to wait for diff command")?;

    // Join writer threads and propagate errors
    writer_a
        .join()
        .map_err(|e| anyhow::anyhow!("Writer A panicked: {e:?}"))??;
    writer_b
        .join()
        .map_err(|e| anyhow::anyhow!("Writer B panicked: {e:?}"))??;

    // diff returns 0 if identical, 1 if different, 2 on error
    if status.code() == Some(2) {
        Err(anyhow::anyhow!("diff command failed with an error"))
    } else {
        Ok(())
    }
}
