use std::path::PathBuf;

use mime_guess::Mime;
use tracing::{debug, error};
use uuid::Uuid;

/// Manages attachment files on disk.
///
/// File layout: `<root>/uploads/<first 2 hex chars of uuid>/<remainder of
/// uuid>.<ext>` Extension is derived from content_type via mime_guess,
/// sanitized to [a-z0-9]+.
#[derive(Clone)]
pub struct FileStore {
    root: PathBuf,
}

impl FileStore {
    /// Create a new FileStore rooted at the given directory.
    /// The `uploads` subdirectory will be created if it doesn't exist.
    pub async fn new(root: PathBuf) -> std::io::Result<Self> {
        let uploads_dir = root.join("uploads");
        tokio::fs::create_dir_all(&uploads_dir).await?;
        debug!(?uploads_dir, "FileStore initialized");
        Ok(Self { root: uploads_dir })
    }

    /// Write attachment bytes to disk.
    pub async fn write(&self, uuid: Uuid, content_type: &str, data: &[u8]) -> std::io::Result<()> {
        let path = self.path(uuid, content_type);
        if let Some(parent) = path.parent() {
            tokio::fs::create_dir_all(parent).await?;
        }
        tokio::fs::write(&path, data).await?;
        debug!(?path, size = data.len(), "Wrote attachment file");
        Ok(())
    }

    /// Open an attachment file for streaming.
    /// Returns None if the file doesn't exist (row exists but file lost).
    pub async fn open(
        &self,
        uuid: Uuid,
        content_type: &str,
    ) -> std::io::Result<Option<tokio::fs::File>> {
        let path = self.path(uuid, content_type);
        match tokio::fs::File::open(&path).await {
            Ok(file) => {
                debug!(?path, "Opened attachment file");
                Ok(Some(file))
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                error!(?path, "Attachment file missing on disk");
                Ok(None)
            }
            Err(e) => {
                error!(?path, error = %e, "Failed to open attachment file");
                Err(e)
            }
        }
    }

    /// Delete an attachment file. Missing files are Ok (idempotent).
    pub async fn delete(&self, uuid: Uuid, content_type: &str) -> std::io::Result<()> {
        let path = self.path(uuid, content_type);
        match tokio::fs::remove_file(&path).await {
            Ok(()) => {
                debug!(?path, "Deleted attachment file");
                Ok(())
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                debug!(?path, "Attachment file already missing");
                Ok(())
            }
            Err(e) => {
                error!(?path, error = %e, "Failed to delete attachment file");
                Err(e)
            }
        }
    }

    /// Derive the file path from uuid and content_type.
    /// Layout: `<root>/<first 2 hex>/<remainder>.<ext>`
    fn path(&self, uuid: Uuid, content_type: &str) -> PathBuf {
        let hex = uuid.simple().to_string();
        let shard = &hex[..2];
        let remainder = &hex[2..];
        let ext = Self::sanitize_extension(content_type);
        if ext.is_empty() {
            self.root.join(shard).join(remainder)
        } else {
            self.root.join(shard).join(format!("{}.{}", remainder, ext))
        }
    }

    /// Sanitize content_type into a file extension.
    /// Uses a preference map for common types, then falls back to mime_guess.
    fn sanitize_extension(content_type: &str) -> String {
        // Preference map for common MIME types where we know the standard
        // extension
        let preferred = match content_type {
            "image/png" => "png",
            "image/jpeg" => "jpg",
            "image/gif" => "gif",
            "image/webp" => "webp",
            "text/plain" => "txt",
            "text/html" => "html",
            "text/css" => "css",
            "text/javascript" => "js",
            "application/json" => "json",
            "application/pdf" => "pdf",
            "application/zip" => "zip",
            "application/gzip" => "gz",
            _ => {
                // Fall back to mime_guess for unknown types
                let mime: Mime = match content_type.parse() {
                    Ok(m) => m,
                    Err(_) => return String::new(),
                };

                return mime_guess::get_mime_extensions(&mime)
                    .and_then(|exts| exts.first())
                    .map(|s| s.to_string())
                    .unwrap_or_default()
                    .chars()
                    .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
                    .collect();
            }
        };

        preferred.to_string()
    }
}

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

    #[test]
    fn sanitize_extension_common_types() {
        assert_eq!(FileStore::sanitize_extension("image/png"), "png");
        assert_eq!(FileStore::sanitize_extension("image/jpeg"), "jpg");
        assert_eq!(FileStore::sanitize_extension("text/plain"), "txt");
        assert_eq!(FileStore::sanitize_extension("application/pdf"), "pdf");
    }

    #[test]
    fn sanitize_extension_unknown_type() {
        assert_eq!(FileStore::sanitize_extension("application/x-unknown"), "");
    }

    #[test]
    fn sanitize_extension_filters_chars() {
        // Even if mime_guess returns something weird, we filter
        // This is defensive since mime_guess should return clean extensions
        let ext = FileStore::sanitize_extension("text/plain");
        assert!(
            ext.chars()
                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
        );
    }

    #[test]
    fn path_derivation() {
        let store = FileStore {
            root: PathBuf::from("/data/uploads"),
        };
        let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();

        let path = store.path(uuid, "image/png");
        assert_eq!(
            path,
            PathBuf::from("/data/uploads/12/345678123456781234567812345678.png")
        );
    }

    #[test]
    fn path_derivation_no_extension() {
        let store = FileStore {
            root: PathBuf::from("/data/uploads"),
        };
        let uuid = Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();

        let path = store.path(uuid, "application/x-unknown");
        assert_eq!(
            path,
            PathBuf::from("/data/uploads/12/345678123456781234567812345678")
        );
    }
}
