use anyhow::Context;
use reqwest::{Client, ClientBuilder};
use serde::{Deserialize, Serialize};
use std::sync::Arc;

/// Errors returned by the Boppo device HTTP API.
#[derive(Debug, thiserror::Error)]
pub enum DeviceError {
    /// The device returned HTTP 401 Unauthorized (wrong or expired password).
    #[error("device returned 401 Unauthorized")]
    Unauthorized,
}

/// Called with `(bytes_sent, total_bytes)` as data is uploaded.
pub type ProgressCallback = Arc<dyn Fn(u64, u64) + Send + Sync>;

/// Creates a per-file [`ProgressCallback`] given `(device_path, total_bytes)`.
pub type ProgressFactory = Arc<dyn Fn(&str, u64) -> ProgressCallback + Send + Sync>;

/// Called with the device directory path each time [`sync_dir`](crate::device::sync_dir) enters a directory.
pub type DirStatusCallback = Arc<dyn Fn(&str) + Send + Sync>;

/// A single entry returned by [`BoppoDevice::read_dir`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DirEntry {
    pub name: String,
    pub size: u64,
    pub is_dir: bool,
}

/// Async interface to a Boppo device's HTTP API.
///
/// All methods require a bearer token obtained via the pairing flow
/// ([`get_password`]). Devices use self-signed TLS certificates, so the
/// concrete implementation skips certificate verification.
///
/// Enable the `mock` crate feature to get a `MockBoppoDevice` generated by
/// `mockall` for use in tests.
#[cfg_attr(any(feature = "mock", test), mockall::automock)]
#[async_trait::async_trait]
pub trait BoppoDevice: Send + Sync {
    /// Fetch device information as a JSON value.
    async fn get_info(&self) -> anyhow::Result<serde_json::Value>;

    /// Set the human-readable name shown on the device.
    async fn set_device_name(&self, name: &str) -> anyhow::Result<()>;

    /// List the contents of a directory on the device.
    ///
    /// Returns an empty vec if the directory does not exist (HTTP 404).
    async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<DirEntry>>;

    /// Upload `data` to `path` on the device, creating the file if needed.
    ///
    /// If `progress` is provided it is called with `(bytes_sent, total_bytes)`
    /// as chunks are written to the network.
    async fn upload_file(
        &self,
        path: &str,
        data: Vec<u8>,
        progress: Option<ProgressCallback>,
    ) -> anyhow::Result<()>;

    /// Download a file from `path` on the device.
    async fn download_file(&self, path: &str) -> anyhow::Result<Vec<u8>>;

    /// Remove a single file at `path` on the device.
    async fn remove_file(&self, path: &str) -> anyhow::Result<()>;

    /// Remove an empty directory at `path` on the device.
    async fn remove_dir(&self, path: &str) -> anyhow::Result<()>;

    /// Recursively remove a directory and all its contents at `path`.
    async fn remove_dir_all(&self, path: &str) -> anyhow::Result<()>;

    /// Execute a command on the device and return its output.
    async fn run_command(&self, command: &str) -> anyhow::Result<String>;
}

/// HTTPS client for a single Boppo device.
///
/// Certificate verification is disabled because devices use self-signed certs.
/// Construct with [`BoppoDeviceHttpsClient::new`] for most use cases, or
/// [`BoppoDeviceHttpsClient::with_client`] to supply a pre-configured
/// [`reqwest::Client`] (useful in tests).
pub struct BoppoDeviceHttpsClient {
    base_url: String,
    bearer_token: String,
    client: Client,
}

impl BoppoDeviceHttpsClient {
    /// Create a client that accepts self-signed certificates.
    pub fn new(base_url: impl Into<String>, bearer_token: impl Into<String>) -> anyhow::Result<Self> {
        let client = ClientBuilder::new()
            .danger_accept_invalid_certs(true)
            .build()
            .context("failed to build reqwest client")?;
        Ok(Self {
            base_url: base_url.into(),
            bearer_token: bearer_token.into(),
            client,
        })
    }

    /// Create a client using a caller-supplied [`reqwest::Client`].
    pub fn with_client(
        base_url: impl Into<String>,
        bearer_token: impl Into<String>,
        client: Client,
    ) -> Self {
        Self {
            base_url: base_url.into(),
            bearer_token: bearer_token.into(),
            client,
        }
    }
}

#[async_trait::async_trait]
impl BoppoDevice for BoppoDeviceHttpsClient {
    async fn get_info(&self) -> anyhow::Result<serde_json::Value> {
        let url = format!("{}/get-info", self.base_url);
        let resp = self
            .client
            .get(&url)
            .bearer_auth(&self.bearer_token)
            .send()
            .await
            .context("get-info request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("get-info returned status {}", status);
        }
        let value = resp.json().await.context("failed to parse get-info response as JSON")?;
        Ok(value)
    }

    async fn set_device_name(&self, name: &str) -> anyhow::Result<()> {
        let url = format!("{}/set-device-name", self.base_url);
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.bearer_token)
            .body(name.to_owned())
            .send()
            .await
            .context("set-device-name request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("set-device-name returned status {}", status);
        }
        Ok(())
    }

    async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<DirEntry>> {
        let url = format!("{}/files/read-dir", self.base_url);
        let resp = self
            .client
            .get(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .send()
            .await
            .context("read-dir request failed")?;
        let status = resp.status().as_u16();
        match status {
            401 => return Err(DeviceError::Unauthorized.into()),
            404 => return Ok(Vec::new()),
            200 => {}
            code => anyhow::bail!("read-dir returned status {}: {}", code, url),
        }
        let text = resp.text().await.context("failed to read read-dir response body")?;
        let mut entries = Vec::new();
        for line in text.lines() {
            if line.starts_with("total") {
                break;
            }
            if let Some(entry) = parse_dir_entry(line) {
                entries.push(entry);
            }
        }
        Ok(entries)
    }

    async fn upload_file(
        &self,
        path: &str,
        data: Vec<u8>,
        progress: Option<ProgressCallback>,
    ) -> anyhow::Result<()> {
        let url = format!("{}/files/upload", self.base_url);
        let content_length = data.len() as u64;

        let body = if let Some(cb) = progress {
            const CHUNK: usize = 65536; // 64 KiB
            let total = content_length;
            let chunks: Vec<Vec<u8>> = data.chunks(CHUNK).map(<[u8]>::to_vec).collect();
            let mut sent = 0u64;
            let stream = futures_util::stream::iter(chunks.into_iter().map(move |chunk| {
                sent += chunk.len() as u64;
                cb(sent, total);
                Ok::<Vec<u8>, std::io::Error>(chunk)
            }));
            reqwest::Body::wrap_stream(stream)
        } else {
            reqwest::Body::from(data)
        };

        let resp = self
            .client
            .post(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .header(reqwest::header::CONTENT_LENGTH, content_length)
            .body(body)
            .send()
            .await
            .context("upload request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("upload returned status {}", status);
        }
        Ok(())
    }

    async fn download_file(&self, path: &str) -> anyhow::Result<Vec<u8>> {
        let url = format!("{}/files/download", self.base_url);
        let resp = self
            .client
            .get(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .send()
            .await
            .context("download request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("download returned status {}", status);
        }
        let bytes = resp.bytes().await.context("failed to read download response body")?;
        Ok(bytes.to_vec())
    }

    async fn remove_file(&self, path: &str) -> anyhow::Result<()> {
        let url = format!("{}/files/remove-file", self.base_url);
        let resp = self
            .client
            .post(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .body("")
            .send()
            .await
            .context("remove-file request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("remove-file returned status {}", status);
        }
        Ok(())
    }

    async fn remove_dir(&self, path: &str) -> anyhow::Result<()> {
        let url = format!("{}/files/remove-dir", self.base_url);
        let resp = self
            .client
            .post(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .body("")
            .send()
            .await
            .context("remove-dir request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("remove-dir returned status {}", status);
        }
        Ok(())
    }

    async fn remove_dir_all(&self, path: &str) -> anyhow::Result<()> {
        let url = format!("{}/files/remove-dir-all", self.base_url);
        let resp = self
            .client
            .post(&url)
            .query(&[("path", path)])
            .bearer_auth(&self.bearer_token)
            .body("")
            .send()
            .await
            .context("remove-dir-all request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("remove-dir-all returned status {}", status);
        }
        Ok(())
    }

    async fn run_command(&self, command: &str) -> anyhow::Result<String> {
        let url = format!("{}/command", self.base_url);
        let resp = self
            .client
            .post(&url)
            .bearer_auth(&self.bearer_token)
            .body(command.to_owned())
            .send()
            .await
            .context("command request failed")?;
        let status = resp.status().as_u16();
        if status == 401 {
            return Err(DeviceError::Unauthorized.into());
        }
        if status != 200 {
            anyhow::bail!("command returned status {}", status);
        }
        resp.text().await.context("failed to read command response body")
    }
}

/// Initiate the device pairing flow and return the bearer token.
///
/// Polls `POST /get-password?requestid=<request_id>` until the device
/// approves or rejects the pairing request.  The response is always JSON:
///
/// - `{"status": "in-progress"}` — the user has not yet responded on the
///   device; the function sleeps 2 seconds and retries.
/// - `{"status": "failed"}` — the user rejected the pairing request;
///   returns `Err`.
/// - `{"status": "success", "password": "..."}` — pairing approved; returns
///   `Ok(password)`.
///
/// On the first iteration a message is printed to stderr asking the user to
/// check the device.
pub async fn get_password(base_url: &str, request_id: u64) -> anyhow::Result<String> {
    let client = ClientBuilder::new()
        .danger_accept_invalid_certs(true)
        .build()
        .context("failed to build reqwest client")?;
    let url = format!("{}/get-password", base_url);
    let request_id_str = request_id.to_string();

    eprintln!("Please check your device and approve the pairing request.");

    loop {
        let resp = client
            .post(&url)
            .query(&[("requestid", request_id_str.as_str())])
            .body("")
            .send()
            .await
            .context("get-password request failed")?;
        let status = resp.status().as_u16();
        if status != 200 {
            anyhow::bail!("get-password returned status {}", status);
        }
        let body: serde_json::Value =
            resp.json().await.context("failed to parse get-password response as JSON")?;
        match body.get("status").and_then(|s| s.as_str()) {
            Some("in-progress") => {
                tokio::time::sleep(std::time::Duration::from_secs(2)).await;
            }
            Some("failed") => {
                anyhow::bail!("pairing was rejected on the device");
            }
            Some("success") => {
                let password = body
                    .get("password")
                    .and_then(|p| p.as_str())
                    .context("get-password success response missing 'password' field")?
                    .to_owned();
                return Ok(password);
            }
            _ => {
                anyhow::bail!("unexpected get-password response: {}", body);
            }
        }
    }
}

/// Parse one line of the `read-dir` response into a [`DirEntry`].
///
/// Expected format: `<type> <size> <timestamp> <name>`
/// where `<type>` is `"d"` for directories and any other value for files.
fn parse_dir_entry(line: &str) -> Option<DirEntry> {
    let mut parts = line.split_ascii_whitespace();
    let type_char = parts.next()?;
    let is_dir = type_char == "d";
    let size: u64 = parts.next()?.parse().ok()?;
    let _timestamp = parts.next()?;
    let name = parts.next()?.to_owned();
    Some(DirEntry { name, size, is_dir })
}
