use std::fs;
use std::io::{self, Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use clap::{Parser, Subcommand};
use shua_zip::{ReadAt, ZipReadStrategy};

struct FileReader {
    file: Mutex<fs::File>,
    size: u64,
}

impl FileReader {
    fn open(path: &Path) -> io::Result<Self> {
        let file = fs::File::open(path)?;
        let size = file.metadata()?.len();
        Ok(Self {
            file: Mutex::new(file),
            size,
        })
    }
}

impl ReadAt for FileReader {
    type Error = io::Error;

    fn size(&self) -> u64 {
        self.size
    }

    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), Self::Error> {
        let mut f = self.file.lock().unwrap();
        f.seek(SeekFrom::Start(offset))?;
        f.read_exact(buf)
    }
}

#[derive(Parser)]
#[command(name = "shua_zip", about = "A slim ZIP tool")]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    List {
        zip_file: PathBuf,
    },
    Extract {
        zip_file: PathBuf,
        output_dir: Option<PathBuf>,
    },
    Create {
        zip_file: PathBuf,
        files: Vec<String>,
    },
    Add {
        zip_file: PathBuf,
        files: Vec<String>,
    },
}

fn cmd_list(path: &Path) -> io::Result<()> {
    let reader = FileReader::open(path)?;
    let archive = shua_zip::ZipArchive::new()
        .with_binary(&reader)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
    println!(
        "{:>10}  {:>10}  {:>8}  {}",
        "Compressed", "Size", "Method", "Name"
    );
    println!("{}", "-".repeat(60));
    for entry in archive.entries() {
        let method = match entry.compression_method {
            0 => "stored",
            8 => "deflate",
            _ => "?",
        };
        println!(
            "{:>10}  {:>10}  {:>8}  {}",
            entry.compressed_size, entry.uncompressed_size, method, entry.name
        );
    }
    Ok(())
}

fn cmd_extract(path: &Path, output_dir: &Path) -> io::Result<()> {
    let reader = FileReader::open(path)?;
    let archive = shua_zip::ZipArchive::new()
        .with_binary(&reader)
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;

    fs::create_dir_all(output_dir)?;

    for i in 0..archive.entries().len() {
        let entry = &archive.entries()[i];
        let out_path = output_dir.join(&entry.name);
        if let Some(parent) = out_path.parent() {
            fs::create_dir_all(parent)?;
        }
        if entry.name.ends_with('/') || entry.name.ends_with('\\') {
            fs::create_dir_all(&out_path)?;
        } else {
            let index = archive
                .find_by_name(&entry.name)
                .ok_or(io::Error::new(io::ErrorKind::InvalidData, "file not found"))?;
            let data = archive
                .read_file_with_strategy(index, &reader, ZipReadStrategy::Merged)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
            fs::write(&out_path, data)?;
            println!("extracted: {}", entry.name);
        }
    }
    Ok(())
}

fn cmd_create(zip_path: &Path, files: &[String]) -> io::Result<()> {
    let mut archive = shua_zip::ZipArchive::new();

    for file_path in files {
        let path = Path::new(file_path);
        let name = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(file_path)
            .to_string();
        let data = fs::read(path)?;
        archive.add_file(name, data);
    }

    let zip_data = archive
        .build()
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
    fs::write(zip_path, &zip_data)?;
    println!(
        "created: {} ({} files)",
        zip_path.display(),
        archive.entries().len()
    );
    Ok(())
}

fn cmd_add(zip_path: &Path, files: &[String]) -> io::Result<()> {
    let mut archive = shua_zip::ZipArchive::new();

    let reader = if zip_path.exists() {
        let r = FileReader::open(zip_path)?;
        archive = archive
            .with_binary(&r)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
        Some(r)
    } else {
        None
    };

    for file_path in files {
        let path = Path::new(file_path);
        let name = path
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(file_path)
            .to_string();
        let data = fs::read(path)?;
        archive.add_file(name, data);
    }

    let zip_data = match &reader {
        Some(r) => archive
            .build_with_readat(r)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?,
        None => archive
            .build()
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?,
    };
    fs::write(zip_path, &zip_data)?;
    println!(
        "updated: {} ({} entries())",
        zip_path.display(),
        archive.entries().len()
    );
    Ok(())
}

fn main() {
    let cli = Cli::parse();

    let result = match cli.command {
        Command::List { zip_file } => cmd_list(&zip_file),
        Command::Extract {
            zip_file,
            output_dir,
        } => {
            let output_dir = output_dir.unwrap_or_else(|| {
                zip_file
                    .file_stem()
                    .map(Path::new)
                    .unwrap_or(Path::new("extracted"))
                    .to_path_buf()
            });
            cmd_extract(&zip_file, &output_dir)
        }
        Command::Create { zip_file, files } => {
            if files.is_empty() {
                eprintln!("Error: no input files specified");
                return;
            }
            cmd_create(&zip_file, &files)
        }
        Command::Add { zip_file, files } => {
            if files.is_empty() {
                eprintln!("Error: no input files specified");
                return;
            }
            cmd_add(&zip_file, &files)
        }
    };

    if let Err(e) = result {
        eprintln!("Error: {}", e);
    }
}
