use anyhow::{Context, Result, bail};
use futures_util::StreamExt;
use serde::{Deserialize, Serialize};
use std::path::Path;

use crate::client::QnapClient;
use crate::output::{FileRow, OutputFormat, print_files, print_kv};

const PAGE_SIZE: usize = 200;

#[derive(Debug, Deserialize)]
struct FileListResponse {
    status: Option<u64>,
    datas: Option<Vec<FileEntry>>,
}

#[derive(Debug, Deserialize)]
struct FileEntry {
    filename: Option<String>,
    isfolder: Option<u8>,
    filesize: Option<String>,
    mt: Option<String>,
    epochmt: Option<u64>,
}

#[derive(Debug, Serialize, PartialEq)]
struct FileListItem {
    entry_type: String,
    name: String,
    size_bytes: Option<u64>,
    modified: Option<String>,
    modified_epoch: Option<u64>,
}

#[derive(Debug, Serialize)]
struct FileStatOutput {
    path: String,
    name: Option<String>,
    entry_type: Option<String>,
    size_bytes: Option<u64>,
    modified: Option<String>,
    modified_epoch: Option<u64>,
    owner: Option<String>,
    group: Option<String>,
    permissions: Option<String>,
    exists: Option<bool>,
    is_link: Option<bool>,
    raw: serde_json::Value,
}

fn check_list_status(status: Option<u64>, path: &str) -> Result<()> {
    match status {
        Some(0) | None => Ok(()),
        Some(5) => bail!("path not found: {}", path),
        Some(20) => bail!("permission denied: {}", path),
        Some(code) => bail!("error listing {}: status={}", path, code),
    }
}

/// Split a remote path into (parent_dir, filename).
///
/// "/Backups/files/foo.txt" → ("/Backups/files", "foo.txt")
/// "/Backups"               → ("/",              "Backups")
fn split_path(path: &str) -> (&str, &str) {
    let path = path.trim_end_matches('/');
    if path.is_empty() || path == "/" {
        return ("/", "");
    }
    match path.rfind('/') {
        None => ("/", path),
        Some(0) => ("/", &path[1..]),
        Some(i) => (&path[..i], &path[i + 1..]),
    }
}

/// Check the status field of a mutating file operation response.
///
/// QNAP uses 0 or 1 to indicate success depending on firmware version.
fn check_op_status(resp: &serde_json::Value, op: &str, path: &str) -> Result<()> {
    match resp.get("status").and_then(|s| s.as_u64()) {
        Some(0) | Some(1) | None => Ok(()),
        Some(2) => bail!("{}: destination already exists: {}", op, path),
        Some(5) => bail!("{}: path not found: {}", op, path),
        Some(20) => bail!("{}: permission denied: {}", op, path),
        Some(code) => bail!("{} failed for {}: status={}", op, path, code),
    }
}

fn file_type_label(is_folder: Option<u8>) -> String {
    if is_folder == Some(1) {
        "dir".to_string()
    } else {
        "file".to_string()
    }
}

fn parse_size_bytes(value: Option<String>) -> Option<u64> {
    value.and_then(|raw| raw.trim().parse::<u64>().ok())
}

fn list_item_from_entry(entry: FileEntry) -> FileListItem {
    FileListItem {
        entry_type: file_type_label(entry.isfolder),
        name: entry.filename.unwrap_or_else(|| "?".to_string()),
        size_bytes: parse_size_bytes(entry.filesize),
        modified: entry.mt,
        modified_epoch: entry.epochmt,
    }
}

fn human_row(item: &FileListItem) -> FileRow {
    FileRow {
        file_type: item.entry_type.clone(),
        name: item.name.clone(),
        size: item
            .size_bytes
            .map(|value| value.to_string())
            .unwrap_or_else(|| "-".to_string()),
        modified: item.modified.clone().unwrap_or_else(|| "-".to_string()),
    }
}

fn bool_from_u8(value: Option<u8>) -> Option<bool> {
    value.map(|value| value != 0)
}

fn stat_output(path: &str, entry: &serde_json::Value) -> FileStatOutput {
    let is_folder = entry
        .get("isfolder")
        .and_then(|value| value.as_u64())
        .map(|value| value as u8);
    let size_bytes = entry
        .get("filesize")
        .and_then(|value| value.as_str())
        .and_then(|value| value.trim().parse::<u64>().ok());

    FileStatOutput {
        path: path.to_string(),
        name: entry
            .get("filename")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        entry_type: Some(file_type_label(is_folder)),
        size_bytes,
        modified: entry
            .get("mt")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        modified_epoch: entry.get("epochmt").and_then(|value| value.as_u64()),
        owner: entry
            .get("owner")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        group: entry
            .get("group")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        permissions: entry
            .get("privilege")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        exists: bool_from_u8(
            entry
                .get("exist")
                .and_then(|value| value.as_u64())
                .map(|value| value as u8),
        ),
        is_link: bool_from_u8(
            entry
                .get("is_link")
                .and_then(|value| value.as_u64())
                .map(|value| value as u8),
        ),
        raw: entry.clone(),
    }
}

async fn fetch_page(client: &QnapClient, path: &str, start: usize) -> Result<Vec<FileListItem>> {
    let limit_s = PAGE_SIZE.to_string();
    let start_s = start.to_string();
    let resp: FileListResponse = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[
                ("func", "get_list"),
                ("path", path),
                ("limit", &limit_s),
                ("start", &start_s),
                ("type", "both"),
                ("sortby", "filename"),
                ("order", "ASC"),
            ],
        )
        .await?;
    check_list_status(resp.status, path)?;
    Ok(resp
        .datas
        .unwrap_or_default()
        .into_iter()
        .map(list_item_from_entry)
        .collect())
}

/// Case-insensitive glob match. Supports `*` (any sequence) and `?` (single char).
fn glob_match(pattern: &[u8], name: &[u8]) -> bool {
    match (pattern.first(), name.first()) {
        (None, None) => true,
        (Some(b'*'), _) => {
            glob_match(&pattern[1..], name) || (!name.is_empty() && glob_match(pattern, &name[1..]))
        }
        (Some(b'?'), Some(_)) => glob_match(&pattern[1..], &name[1..]),
        (Some(a), Some(b)) if a.eq_ignore_ascii_case(b) => glob_match(&pattern[1..], &name[1..]),
        _ => false,
    }
}

pub async fn list(
    client: &QnapClient,
    path: &str,
    all: bool,
    fmt: OutputFormat,
    limit: usize,
    offset: usize,
    _fields: Option<&str>,
) -> Result<()> {
    #[derive(Serialize)]
    struct PagedFiles<'a> {
        items: &'a [FileListItem],
        total: usize,
        limit: usize,
        offset: usize,
    }

    let mut all_items: Vec<FileListItem> = Vec::new();
    let mut start = 0usize;

    loop {
        let page = fetch_page(client, path, start).await?;
        let page_len = page.len();
        all_items.extend(page);

        if page_len < PAGE_SIZE || !all {
            if !all && page_len == PAGE_SIZE {
                eprintln!(
                    "  (showing first {} results - use --all to fetch everything)",
                    PAGE_SIZE
                );
            }
            break;
        }

        start += PAGE_SIZE;
    }

    let total = all_items.len();
    let page_start = offset.min(total);
    let page_end = (offset + limit).min(total);
    let page = &all_items[page_start..page_end];

    if fmt.is_json() {
        let paged = PagedFiles {
            items: page,
            total,
            limit,
            offset,
        };
        println!(
            "{}",
            serde_json::to_string_pretty(&paged).unwrap_or_default()
        );
        return Ok(());
    }

    let rows: Vec<FileRow> = page.iter().map(human_row).collect();
    print_files(&rows);
    Ok(())
}

pub async fn list_recursive(client: &QnapClient, root: &str, fmt: OutputFormat) -> Result<()> {
    #[derive(Serialize)]
    struct RecursiveEntry {
        path: String,
        entry_type: String,
        size_bytes: Option<u64>,
        modified: Option<String>,
    }

    let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
    queue.push_back(root.trim_end_matches('/').to_string());
    let mut results: Vec<RecursiveEntry> = Vec::new();

    while let Some(dir) = queue.pop_front() {
        let mut start = 0;
        loop {
            let page = fetch_page(client, &dir, start).await?;
            let page_len = page.len();
            for item in page {
                let full_path = format!("{}/{}", dir, item.name);
                if item.entry_type == "dir" {
                    queue.push_back(full_path.clone());
                }
                if fmt.is_json() {
                    results.push(RecursiveEntry {
                        path: full_path,
                        entry_type: item.entry_type,
                        size_bytes: item.size_bytes,
                        modified: item.modified,
                    });
                } else {
                    println!("{}", full_path);
                }
            }
            if page_len < PAGE_SIZE {
                break;
            }
            start += PAGE_SIZE;
        }
    }

    if fmt.is_json() {
        println!("{}", serde_json::to_string_pretty(&results)?);
    }
    Ok(())
}

pub async fn find(client: &QnapClient, root: &str, pattern: &str, fmt: OutputFormat) -> Result<()> {
    #[derive(Serialize)]
    struct FindResult {
        path: String,
        entry_type: String,
        size_bytes: Option<u64>,
        modified: Option<String>,
    }

    let pat = pattern.as_bytes();
    let mut queue: std::collections::VecDeque<String> = std::collections::VecDeque::new();
    queue.push_back(root.trim_end_matches('/').to_string());

    let mut results: Vec<FindResult> = Vec::new();

    while let Some(dir) = queue.pop_front() {
        let mut start = 0;
        loop {
            let page = fetch_page(client, &dir, start).await?;
            let page_len = page.len();

            for item in page {
                let full_path = format!("{}/{}", dir, item.name);
                if glob_match(pat, item.name.as_bytes()) {
                    if fmt.is_json() {
                        results.push(FindResult {
                            path: full_path.clone(),
                            entry_type: item.entry_type.clone(),
                            size_bytes: item.size_bytes,
                            modified: item.modified.clone(),
                        });
                    } else {
                        println!("{}", full_path);
                    }
                }
                if item.entry_type == "dir" {
                    queue.push_back(full_path);
                }
            }

            if page_len < PAGE_SIZE {
                break;
            }
            start += PAGE_SIZE;
        }
    }

    if fmt.is_json() {
        println!("{}", serde_json::to_string_pretty(&results)?);
    }

    Ok(())
}

pub async fn stat(client: &QnapClient, path: &str, fmt: OutputFormat) -> Result<()> {
    let resp: serde_json::Value = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[("func", "stat"), ("path", path)],
        )
        .await?;

    let status = resp.get("status").and_then(|value| value.as_u64());
    match status {
        Some(0) | None => {}
        Some(5) => bail!("path not found: {}", path),
        Some(20) => bail!("permission denied: {}", path),
        Some(code) => bail!("error getting stat for {}: status={}", path, code),
    }

    let entry = resp
        .get("datas")
        .and_then(|value| value.as_array())
        .and_then(|values| values.first())
        .unwrap_or(&resp);
    let stat = stat_output(path, entry);

    if fmt.is_json() {
        println!(
            "{}",
            serde_json::to_string_pretty(&stat).unwrap_or_default()
        );
        return Ok(());
    }

    let mut pairs = Vec::new();
    pairs.push(("path".to_string(), stat.path));
    if let Some(value) = stat.name {
        pairs.push(("name".to_string(), value));
    }
    if let Some(value) = stat.entry_type {
        pairs.push(("type".to_string(), value));
    }
    if let Some(value) = stat.size_bytes {
        pairs.push(("size_bytes".to_string(), value.to_string()));
    }
    if let Some(value) = stat.modified {
        pairs.push(("modified".to_string(), value));
    }
    if let Some(value) = stat.modified_epoch {
        pairs.push(("modified_epoch".to_string(), value.to_string()));
    }
    if let Some(value) = stat.owner {
        pairs.push(("owner".to_string(), value));
    }
    if let Some(value) = stat.group {
        pairs.push(("group".to_string(), value));
    }
    if let Some(value) = stat.permissions {
        pairs.push(("permissions".to_string(), value));
    }
    if let Some(value) = stat.exists {
        pairs.push(("exists".to_string(), value.to_string()));
    }
    if let Some(value) = stat.is_link {
        pairs.push(("is_link".to_string(), value.to_string()));
    }

    print_kv(&pairs);
    Ok(())
}

pub async fn mkdir(client: &QnapClient, path: &str) -> Result<()> {
    let (parent, name) = split_path(path);
    if name.is_empty() {
        bail!("invalid path: {}", path);
    }
    let resp: serde_json::Value = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[
                ("func", "createdir"),
                ("dest_path", parent),
                ("dest_folder", name),
            ],
        )
        .await?;
    check_op_status(&resp, "mkdir", path)
}

pub async fn rm(client: &QnapClient, path: &str) -> Result<()> {
    let (parent, name) = split_path(path);
    if name.is_empty() {
        bail!("cannot delete root path");
    }
    // func and sid go in the URL; file params go in the POST body.
    // file_name is the plain param name (not file_name[0]).
    // force=1 skips the recycle bin and permanently deletes.
    // v=2 enables background-task mode, which is required by the API.
    let resp: serde_json::Value = client
        .post_json_fileop(
            "/cgi-bin/filemanager/utilRequest.cgi",
            "delete",
            &[
                ("file_total", "1"),
                ("file_name", name),
                ("path", parent),
                ("v", "2"),
                ("force", "1"),
            ],
        )
        .await?;
    check_op_status(&resp, "rm", path)
}

pub async fn mv(client: &QnapClient, src: &str, dst: &str) -> Result<()> {
    let (src_parent, src_name) = split_path(src);
    let (dst_parent, dst_name) = split_path(dst);

    if src_name.is_empty() {
        bail!("invalid source path: {}", src);
    }
    if dst_name.is_empty() {
        bail!("invalid destination path: {}", dst);
    }

    if src_parent == dst_parent {
        // Rename within the same directory
        let resp: serde_json::Value = client
            .get_json(
                "/cgi-bin/filemanager/utilRequest.cgi",
                &[
                    ("func", "rename"),
                    ("path", src_parent),
                    ("source_name", src_name),
                    ("dest_name", dst_name),
                ],
            )
            .await?;
        check_op_status(&resp, "mv", src)
    } else if src_name == dst_name {
        // Move to a different directory, keeping the same filename
        let resp: serde_json::Value = client
            .get_json(
                "/cgi-bin/filemanager/utilRequest.cgi",
                &[
                    ("func", "move"),
                    ("source_path", src_parent),
                    ("source_file", src_name),
                    ("dest_path", dst_parent),
                    ("source_total", "1"),
                    ("overwrite", "0"),
                ],
            )
            .await?;
        check_op_status(&resp, "mv", src)
    } else {
        // Move to a different directory then rename
        let resp: serde_json::Value = client
            .get_json(
                "/cgi-bin/filemanager/utilRequest.cgi",
                &[
                    ("func", "move"),
                    ("source_path", src_parent),
                    ("source_file", src_name),
                    ("dest_path", dst_parent),
                    ("source_total", "1"),
                    ("overwrite", "0"),
                ],
            )
            .await?;
        check_op_status(&resp, "mv", src)?;

        let resp: serde_json::Value = client
            .get_json(
                "/cgi-bin/filemanager/utilRequest.cgi",
                &[
                    ("func", "rename"),
                    ("path", dst_parent),
                    ("source_name", src_name),
                    ("dest_name", dst_name),
                ],
            )
            .await?;
        check_op_status(&resp, "mv (rename)", dst)
    }
}

pub async fn cp(client: &QnapClient, src: &str, dst: &str, overwrite: bool) -> Result<()> {
    let (src_parent, src_name) = split_path(src);
    let (dst_parent, dst_name) = split_path(dst);

    if src_name.is_empty() {
        bail!("invalid source path: {}", src);
    }
    if dst_name.is_empty() {
        bail!("invalid destination path: {}", dst);
    }

    // The QNAP copy API copies to dest_path keeping the source filename.
    // Copying within the same directory to a different name is not supported
    // because the API can't rename during copy and a same-dir copy+rename
    // would affect the original file.
    if src_parent == dst_parent && src_name != dst_name {
        bail!(
            "copying to a different name within the same directory is not supported; \
             copy to another directory first, then use 'files mv' to rename"
        );
    }

    let resp: serde_json::Value = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[
                ("func", "copy"),
                ("source_path", src_parent),
                ("source_file", src_name),
                ("dest_path", dst_parent),
                ("source_total", "1"),
                ("overwrite", if overwrite { "1" } else { "0" }),
            ],
        )
        .await?;
    check_op_status(&resp, "cp", src)?;

    // If the destination filename differs from the source, rename after copying.
    // The copy API places the file under src_name in dst_parent; we wait briefly
    // for the async copy process to complete before renaming.
    if src_name != dst_name {
        tokio::time::sleep(std::time::Duration::from_millis(500)).await;

        let resp: serde_json::Value = client
            .get_json(
                "/cgi-bin/filemanager/utilRequest.cgi",
                &[
                    ("func", "rename"),
                    ("path", dst_parent),
                    ("source_name", src_name),
                    ("dest_name", dst_name),
                ],
            )
            .await?;
        check_op_status(&resp, "cp (rename)", dst)?;
    }

    Ok(())
}

pub async fn upload(
    client: &QnapClient,
    local: &Path,
    remote_dir: &str,
    overwrite: bool,
) -> Result<()> {
    let filename = local
        .file_name()
        .and_then(|n| n.to_str())
        .with_context(|| format!("invalid local path: {}", local.display()))?;

    let data =
        std::fs::read(local).with_context(|| format!("failed to read {}", local.display()))?;

    let resp = client
        .upload_file(remote_dir, filename, data, overwrite)
        .await?;
    check_op_status(&resp, "upload", filename)
}

pub async fn download(client: &QnapClient, remote: &str, local: Option<&Path>) -> Result<()> {
    let (source_path, source_file) = split_path(remote);
    if source_file.is_empty() {
        bail!("cannot download a directory");
    }

    let resp = client.get_file_response(source_path, source_file).await?;

    // Determine output destination
    let out_path = local.map(|p| p.to_path_buf()).unwrap_or_else(|| {
        std::env::current_dir()
            .unwrap_or_default()
            .join(source_file)
    });

    let mut file = std::fs::File::create(&out_path)
        .with_context(|| format!("failed to create {}", out_path.display()))?;

    let mut stream = resp.bytes_stream();
    while let Some(chunk) = stream.next().await {
        let chunk = chunk.context("error reading download stream")?;
        std::io::Write::write_all(&mut file, &chunk)
            .with_context(|| format!("failed to write to {}", out_path.display()))?;
    }

    eprintln!("  saved to {}", out_path.display());
    Ok(())
}

/// Create a remote directory, treating "already exists" as success.
async fn mkdir_idempotent(client: &QnapClient, path: &str) -> Result<()> {
    let (parent, name) = split_path(path);
    if name.is_empty() {
        return Ok(());
    }
    let resp: serde_json::Value = client
        .get_json(
            "/cgi-bin/filemanager/utilRequest.cgi",
            &[
                ("func", "createdir"),
                ("dest_path", parent),
                ("dest_folder", name),
            ],
        )
        .await?;
    match resp.get("status").and_then(|s| s.as_u64()) {
        Some(0) | Some(1) | Some(2) | None => Ok(()),
        Some(5) => bail!("mkdir: path not found: {}", path),
        Some(20) => bail!("mkdir: permission denied: {}", path),
        Some(code) => bail!("mkdir failed for {}: status={}", path, code),
    }
}

pub async fn upload_recursive(
    client: &QnapClient,
    local: &Path,
    remote_dir: &str,
    overwrite: bool,
) -> Result<()> {
    if !local.is_dir() {
        return upload(client, local, remote_dir, overwrite).await;
    }

    // Queue of (local_dir, remote_dir) pairs to process
    let mut queue: std::collections::VecDeque<(std::path::PathBuf, String)> =
        std::collections::VecDeque::new();
    queue.push_back((
        local.to_path_buf(),
        remote_dir.trim_end_matches('/').to_string(),
    ));

    let mut files_uploaded = 0u64;

    while let Some((local_dir, remote)) = queue.pop_front() {
        let entries = std::fs::read_dir(&local_dir)
            .with_context(|| format!("failed to read directory {}", local_dir.display()))?;

        for entry in entries {
            let entry = entry
                .with_context(|| format!("failed to read entry in {}", local_dir.display()))?;
            let path = entry.path();
            let name = path
                .file_name()
                .and_then(|n| n.to_str())
                .with_context(|| format!("invalid filename: {}", path.display()))?
                .to_string();
            let remote_path = format!("{}/{}", remote, name);

            if path.is_dir() {
                mkdir_idempotent(client, &remote_path).await?;
                queue.push_back((path, remote_path));
            } else if path.is_file() {
                eprintln!("  uploading {}", remote_path);
                let data = std::fs::read(&path)
                    .with_context(|| format!("failed to read {}", path.display()))?;
                let resp = client.upload_file(&remote, &name, data, overwrite).await?;
                check_op_status(&resp, "upload", &name)?;
                files_uploaded += 1;
            }
        }
    }

    eprintln!("  {} file(s) uploaded", files_uploaded);
    Ok(())
}

pub async fn download_recursive(client: &QnapClient, remote: &str, local: &Path) -> Result<()> {
    let remote = remote.trim_end_matches('/');

    // Create the local root directory
    std::fs::create_dir_all(local)
        .with_context(|| format!("failed to create directory {}", local.display()))?;

    // Queue of (remote_dir, local_dir) pairs
    let mut queue: std::collections::VecDeque<(String, std::path::PathBuf)> =
        std::collections::VecDeque::new();
    queue.push_back((remote.to_string(), local.to_path_buf()));

    let mut files_downloaded = 0u64;

    while let Some((remote_dir, local_dir)) = queue.pop_front() {
        let mut start = 0;
        loop {
            let page = fetch_page(client, &remote_dir, start).await?;
            let page_len = page.len();

            for item in page {
                let local_path = local_dir.join(&item.name);
                let remote_path = format!("{}/{}", remote_dir, item.name);

                if item.entry_type == "dir" {
                    std::fs::create_dir_all(&local_path).with_context(|| {
                        format!("failed to create directory {}", local_path.display())
                    })?;
                    queue.push_back((remote_path, local_path));
                } else {
                    eprintln!("  downloading {}", remote_path);
                    let (source_path, source_file) = split_path(&remote_path);
                    let resp = client.get_file_response(source_path, source_file).await?;
                    let mut file = std::fs::File::create(&local_path)
                        .with_context(|| format!("failed to create {}", local_path.display()))?;
                    let mut stream = resp.bytes_stream();
                    while let Some(chunk) = stream.next().await {
                        let chunk = chunk.context("error reading download stream")?;
                        std::io::Write::write_all(&mut file, &chunk).with_context(|| {
                            format!("failed to write to {}", local_path.display())
                        })?;
                    }
                    files_downloaded += 1;
                }
            }

            if page_len < PAGE_SIZE {
                break;
            }
            start += PAGE_SIZE;
        }
    }

    eprintln!(
        "  {} file(s) downloaded to {}",
        files_downloaded,
        local.display()
    );
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn split_path_deep() {
        assert_eq!(
            split_path("/Backups/files/foo.txt"),
            ("/Backups/files", "foo.txt")
        );
    }

    #[test]
    fn split_path_share_root() {
        assert_eq!(split_path("/Backups"), ("/", "Backups"));
    }

    #[test]
    fn split_path_trailing_slash() {
        assert_eq!(split_path("/Backups/files/"), ("/Backups", "files"));
    }

    #[test]
    fn split_path_no_leading_slash() {
        assert_eq!(split_path("file.txt"), ("/", "file.txt"));
    }

    #[test]
    fn check_op_status_success_zero() {
        let v = serde_json::json!({"status": 0});
        assert!(check_op_status(&v, "op", "/path").is_ok());
    }

    #[test]
    fn check_op_status_success_one() {
        let v = serde_json::json!({"status": 1});
        assert!(check_op_status(&v, "op", "/path").is_ok());
    }

    #[test]
    fn check_op_status_not_found() {
        let v = serde_json::json!({"status": 5});
        let err = check_op_status(&v, "rm", "/missing").unwrap_err();
        assert!(err.to_string().contains("path not found"));
    }

    #[test]
    fn check_op_status_permission_denied() {
        let v = serde_json::json!({"status": 20});
        let err = check_op_status(&v, "mkdir", "/locked").unwrap_err();
        assert!(err.to_string().contains("permission denied"));
    }

    #[test]
    fn check_op_status_already_exists() {
        let v = serde_json::json!({"status": 2});
        let err = check_op_status(&v, "cp", "/dst").unwrap_err();
        assert!(err.to_string().contains("already exists"));
    }

    #[test]
    fn check_op_status_missing_status_field_is_ok() {
        let v = serde_json::json!({"other": "field"});
        assert!(check_op_status(&v, "op", "/path").is_ok());
    }

    #[test]
    fn check_op_status_unknown_code_is_error() {
        let v = serde_json::json!({"status": 99});
        let err = check_op_status(&v, "op", "/path").unwrap_err();
        assert!(err.to_string().contains("status=99"));
    }

    #[test]
    fn glob_match_exact() {
        assert!(glob_match(b"foo.txt", b"foo.txt"));
        assert!(!glob_match(b"foo.txt", b"bar.txt"));
    }

    #[test]
    fn glob_match_star_suffix() {
        assert!(glob_match(b"*.txt", b"notes.txt"));
        assert!(glob_match(b"*.txt", b"a.txt"));
        assert!(!glob_match(b"*.txt", b"notes.md"));
    }

    #[test]
    fn glob_match_star_prefix() {
        assert!(glob_match(b"backup*", b"backup_2024.tar.gz"));
        assert!(glob_match(b"backup*", b"backup"));
        assert!(!glob_match(b"backup*", b"old_backup"));
    }

    #[test]
    fn glob_match_question_mark() {
        assert!(glob_match(b"file?.txt", b"file1.txt"));
        assert!(glob_match(b"file?.txt", b"fileA.txt"));
        assert!(!glob_match(b"file?.txt", b"file10.txt"));
    }

    #[test]
    fn glob_match_case_insensitive() {
        assert!(glob_match(b"*.TXT", b"notes.txt"));
        assert!(glob_match(b"*.txt", b"NOTES.TXT"));
        assert!(glob_match(b"Backup*", b"backup_2024"));
    }

    #[test]
    fn glob_match_star_only() {
        assert!(glob_match(b"*", b"anything.txt"));
        assert!(glob_match(b"*", b""));
    }

    #[test]
    fn glob_match_multiple_stars() {
        assert!(glob_match(b"*foo*", b"prefoo_suffix.txt"));
        assert!(glob_match(b"*foo*", b"foo"));
        assert!(!glob_match(b"*foo*", b"bar"));
    }
}

#[cfg(test)]
mod api_tests {
    use super::*;
    use crate::client::QnapClient;
    use wiremock::matchers::{body_string_contains, method, path, query_param};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn login_xml(sid: &str) -> String {
        format!(
            "<QDocRoot>\
             <authPassed><![CDATA[1]]></authPassed>\
             <authSid><![CDATA[{}]]></authSid>\
             <errorValue><![CDATA[]]></errorValue>\
             </QDocRoot>",
            sid
        )
    }

    async fn logged_in(server: &MockServer) -> QnapClient {
        Mock::given(method("POST"))
            .and(path("/cgi-bin/authLogin.cgi"))
            .respond_with(ResponseTemplate::new(200).set_body_string(login_xml("test-sid")))
            .mount(server)
            .await;
        let mut client = QnapClient::new_for_test(server.uri()).unwrap();
        client.login("admin", "pass").await.unwrap();
        client
    }

    // --- mkdir ---

    #[tokio::test]
    async fn mkdir_sends_createdir_with_split_path() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "createdir"))
            .and(query_param("dest_path", "/Public"))
            .and(query_param("dest_folder", "mydir"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        mkdir(&client, "/Public/mydir").await.unwrap();
    }

    #[tokio::test]
    async fn mkdir_propagates_permission_denied() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":20}"#))
            .mount(&server)
            .await;

        let err = mkdir(&client, "/Public/locked").await.unwrap_err();
        assert!(err.to_string().contains("permission denied"));
    }

    // --- rm ---

    #[tokio::test]
    async fn rm_posts_with_func_and_sid_in_url() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("POST"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "delete"))
            .and(query_param("sid", "test-sid"))
            .and(body_string_contains("file_name=foo.txt"))
            .and(body_string_contains("force=1"))
            .and(body_string_contains("v=2"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":1,"pid":7}"#))
            .expect(1)
            .mount(&server)
            .await;

        rm(&client, "/Public/foo.txt").await.unwrap();
    }

    #[tokio::test]
    async fn rm_rejects_root_path() {
        let server = MockServer::start().await;
        // No login needed — error is returned before any HTTP call.
        let client = QnapClient::new_for_test(server.uri()).unwrap();
        let err = rm(&client, "/").await.unwrap_err();
        assert!(err.to_string().contains("cannot delete root path"));
    }

    // --- mv ---

    #[tokio::test]
    async fn mv_same_dir_uses_rename_func() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "rename"))
            .and(query_param("path", "/Public"))
            .and(query_param("source_name", "old.txt"))
            .and(query_param("dest_name", "new.txt"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        mv(&client, "/Public/old.txt", "/Public/new.txt")
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn mv_cross_dir_same_name_uses_move_func() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "move"))
            .and(query_param("source_path", "/Source"))
            .and(query_param("source_file", "file.txt"))
            .and(query_param("dest_path", "/Dest"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        mv(&client, "/Source/file.txt", "/Dest/file.txt")
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn mv_cross_dir_different_name_uses_move_then_rename() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "move"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "rename"))
            .and(query_param("source_name", "old.txt"))
            .and(query_param("dest_name", "new.txt"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        mv(&client, "/Source/old.txt", "/Dest/new.txt")
            .await
            .unwrap();
    }

    // --- cp ---

    #[tokio::test]
    async fn cp_same_dir_different_name_is_rejected() {
        let server = MockServer::start().await;
        // No login needed — error is returned before any HTTP call.
        let client = QnapClient::new_for_test(server.uri()).unwrap();
        let err = cp(&client, "/Public/a.txt", "/Public/b.txt", false)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("same directory"));
    }

    #[tokio::test]
    async fn cp_cross_dir_same_name_sends_copy_request() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "copy"))
            .and(query_param("source_path", "/Source"))
            .and(query_param("source_file", "file.txt"))
            .and(query_param("dest_path", "/Dest"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":1,"pid":5}"#))
            .expect(1)
            .mount(&server)
            .await;

        cp(&client, "/Source/file.txt", "/Dest/file.txt", false)
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn cp_cross_dir_different_name_copies_then_renames() {
        let server = MockServer::start().await;
        let client = logged_in(&server).await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "copy"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":1,"pid":5}"#))
            .expect(1)
            .mount(&server)
            .await;

        Mock::given(method("GET"))
            .and(path("/cgi-bin/filemanager/utilRequest.cgi"))
            .and(query_param("func", "rename"))
            .and(query_param("source_name", "orig.txt"))
            .and(query_param("dest_name", "copy.txt"))
            .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":0}"#))
            .expect(1)
            .mount(&server)
            .await;

        cp(&client, "/Source/orig.txt", "/Dest/copy.txt", false)
            .await
            .unwrap();
    }
}

#[cfg(test)]
mod fixture_tests {
    use super::*;

    const FILES_SHARE: &str = include_str!("../../tests/fixtures/ts-xa28a-qts52/files-share.json");

    #[test]
    fn fixture_files_share_parses() {
        let resp: FileListResponse =
            serde_json::from_str(FILES_SHARE).expect("failed to parse files-share fixture");
        assert!(
            matches!(resp.status, None | Some(0)),
            "unexpected error status in files-share fixture: {:?}",
            resp.status
        );
    }

    #[test]
    fn fixture_files_share_entries_have_names() {
        let resp: FileListResponse = serde_json::from_str(FILES_SHARE).unwrap();
        let entries = resp.datas.unwrap_or_default();
        assert!(!entries.is_empty(), "no entries in files-share fixture");
        for entry in &entries {
            assert!(
                entry
                    .filename
                    .as_deref()
                    .map(|value| !value.is_empty())
                    .unwrap_or(false),
                "file entry has empty or missing filename"
            );
        }
    }

    #[test]
    fn fixture_files_share_check_list_status_ok() {
        let resp: FileListResponse = serde_json::from_str(FILES_SHARE).unwrap();
        assert!(
            check_list_status(resp.status, "/Backups").is_ok(),
            "check_list_status failed for files-share fixture"
        );
    }

    #[test]
    fn fixture_files_share_items_are_typed() {
        let resp: FileListResponse = serde_json::from_str(FILES_SHARE).unwrap();
        let items: Vec<FileListItem> = resp
            .datas
            .unwrap_or_default()
            .into_iter()
            .map(list_item_from_entry)
            .collect();
        assert_eq!(items[0].entry_type, "dir");
        assert_eq!(items[0].size_bytes, Some(4096));
        assert!(items[0].modified_epoch.is_some());
    }

    #[test]
    fn stat_output_preserves_typed_and_raw_fields() {
        let resp: serde_json::Value = serde_json::from_str(FILES_SHARE).unwrap();
        let entry = resp
            .get("datas")
            .and_then(|value| value.as_array())
            .and_then(|values| values.first())
            .unwrap();

        let stat = stat_output("/Backups/@Recycle", entry);
        assert_eq!(stat.entry_type.as_deref(), Some("dir"));
        assert_eq!(stat.size_bytes, Some(4096));
        assert_eq!(stat.modified_epoch, Some(1771262677));
        assert!(stat.raw.get("filename").is_some());
    }
}
