//! Filesystem backend for secretx.
//!
//! Absolute paths use a leading `/` in the path component:
//! `secretx:file:/etc/secrets/key` → reads `/etc/secrets/key`.
//! Relative paths: `secretx:file:relative/path` → reads `relative/path`.
//!
//! # Security
//!
//! URIs for this backend must come from trusted sources (compiled-in
//! configuration, administrator-controlled environment variables). URIs from
//! end-user input are not safe: an attacker who controls a URI string could
//! read any file the process can access. Paths containing `..` components are
//! rejected at construction time as a defense-in-depth measure, but that
//! alone is not sufficient if the path root itself is attacker-controlled.
//!
//! ```rust,no_run
//! # async fn example() -> Result<(), secretx_core::SecretError> {
//! use secretx_file::FileBackend;
//! use secretx_core::{SecretStore, SecretValue, WritableSecretStore};
//!
//! // Read
//! let store = FileBackend::from_uri("secretx:file:/etc/secrets/api.key")?;
//! let value = store.get().await?;
//!
//! // Write (requires WritableSecretStore in scope)
//! store.put(SecretValue::new(b"new-secret".to_vec())).await?;
//! # Ok(())
//! # }
//! ```

use secretx_core::{SecretError, SecretStore, SecretUri, SecretValue, WritableSecretStore};
use std::io::Write;

const BACKEND: &str = "file";

/// Map a `tokio::task::JoinError` into `SecretError::Backend`.
fn map_join_error(e: tokio::task::JoinError) -> SecretError {
    SecretError::Backend {
        backend: BACKEND,
        source: e.into(),
    }
}

use std::path::{Component, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use zeroize::Zeroizing;

/// Read a file into a [`Zeroizing<Vec<u8>>`], pre-sized from metadata to
/// avoid reallocation-induced leaks of partial secret bytes.
fn read_file_zeroizing(path: &std::path::Path) -> std::io::Result<Zeroizing<Vec<u8>>> {
    use std::io::Read;
    let mut f = std::fs::File::open(path)?;
    let len = f.metadata().map(|m| m.len() as usize).unwrap_or(0);
    let mut buf = Zeroizing::new(Vec::with_capacity(len));
    f.read_to_end(&mut buf)?;
    Ok(buf)
}

/// Backend that reads a secret from a single file.
///
/// `get` and `refresh` read the entire file. `put` overwrites the file
/// atomically (temp file + rename); on Unix the result is always mode `0600`.
#[derive(Debug)]
pub struct FileBackend {
    path: Arc<PathBuf>,
}

impl FileBackend {
    /// Construct from a `secretx:file:<path>` URI.
    ///
    /// Does not read the file — construction only.
    pub fn from_uri(uri: &str) -> Result<Self, SecretError> {
        Self::from_parsed_uri(&SecretUri::parse(uri)?)
    }

    /// Construct from a pre-parsed [`SecretUri`].
    pub fn from_parsed_uri(parsed: &SecretUri) -> Result<Self, SecretError> {
        if parsed.backend() != BACKEND {
            return Err(SecretError::InvalidUri(format!(
                "expected backend `file`, got `{}`",
                parsed.backend()
            )));
        }
        if parsed.path().is_empty() {
            return Err(SecretError::InvalidUri(
                "file URI requires a path: secretx:file:relative or secretx:file:/absolute".into(),
            ));
        }
        let path = PathBuf::from(parsed.path());
        // Reject .. components as a defense-in-depth measure against accidental
        // path traversal. Legitimate secret paths never need to go up the tree.
        if path.components().any(|c| c == Component::ParentDir) {
            return Err(SecretError::InvalidUri(
                "file URI path must not contain '..' components".into(),
            ));
        }
        // Reject paths with no file_name (e.g. "/" or "foo/").  These would
        // pass from_uri but fail at put() time with an opaque error.
        let file_name = path.file_name().ok_or_else(|| {
            SecretError::InvalidUri(
                "file URI path must end with a file name, not a directory \
                 (e.g. `secretx:file:/etc/secret.txt`, not `secretx:file:/`)"
                    .into(),
            )
        })?;
        // The path originates from a &str (the URI), so it is always valid
        // UTF-8.  Validate this at construction time so write_secret_file can
        // rely on it instead of using to_string_lossy().
        if file_name.to_str().is_none() {
            return Err(SecretError::InvalidUri(
                "file URI path must be valid UTF-8".into(),
            ));
        }
        // Reject unknown query parameters to catch typos early.
        // The file backend has no supported query parameters.
        for key in parsed.param_keys() {
            return Err(SecretError::InvalidUri(format!(
                "file URI: unknown query parameter `{key}`; \
                 the file backend does not support query parameters"
            )));
        }
        Ok(Self { path: Arc::new(path) })
    }
}

#[async_trait::async_trait]
impl SecretStore for FileBackend {
    async fn get(&self) -> Result<SecretValue, SecretError> {
        let path = Arc::clone(&self.path);
        tokio::task::spawn_blocking(move || {
            // Pre-size a Zeroizing buffer from file metadata to avoid
            // reallocation-induced leaks of partial secret bytes.
            read_file_zeroizing(&path)
                .map(SecretValue::from_zeroizing)
                .map_err(|e| match e.kind() {
                    std::io::ErrorKind::NotFound => SecretError::NotFound,
                    _ => SecretError::Backend {
                        backend: BACKEND,
                        source: e.into(),
                    },
                })
        })
        .await
        .map_err(map_join_error)?
    }

    async fn refresh(&self) -> Result<SecretValue, SecretError> {
        self.get().await
    }
}

#[async_trait::async_trait]
impl WritableSecretStore for FileBackend {
    /// Overwrite the file with `value`. The parent directory must exist.
    ///
    /// The file is replaced atomically via a temp-file-then-rename pattern:
    /// data is written to a hidden temp file in the same directory (mode
    /// `0600` on Unix), fsynced, then renamed into place. On Unix the
    /// result always has mode `0600` (owner read/write only). On non-Unix
    /// platforms (e.g. Windows) the file is written with default permissions,
    /// which may be world-readable depending on the system configuration.
    /// If you need restrictive ACLs on Windows, set them separately after
    /// construction.
    ///
    /// **Caveat**: if the write or rename fails, a best-effort cleanup removes
    /// the temp file.  If cleanup itself fails (e.g. the parent directory
    /// becomes read-only), a hidden `0600` temp file containing the secret may
    /// be left in the parent directory.
    async fn put(&self, value: SecretValue) -> Result<(), SecretError> {
        let path = Arc::clone(&self.path);
        // into_bytes() consumes value and returns Zeroizing<Vec<u8>>, keeping
        // secret bytes in a Zeroizing allocation until the closure drops it.
        let bytes = value.into_bytes();
        tokio::task::spawn_blocking(move || {
            write_secret_file(&path, &bytes).map_err(|e| SecretError::Backend {
                backend: BACKEND,
                source: e.into(),
            })
        })
        .await
        .map_err(map_join_error)?
    }
}

/// Counter used to make temp file names unique across concurrent `put()` calls
/// on the same path within a single process.
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);

/// Build a sibling temp-file path for atomic writes.
///
/// Returns `(parent, tmp_path)` where `parent` is the directory containing
/// `path` and `tmp_path` is a unique hidden file in the same directory.
fn make_temp_path(path: &std::path::Path) -> std::io::Result<(PathBuf, PathBuf)> {
    let parent = path
        .parent()
        .unwrap_or(std::path::Path::new("."))
        .to_path_buf();
    let file_name = path.file_name().ok_or_else(|| {
        std::io::Error::new(std::io::ErrorKind::InvalidInput, "path has no file name")
    })?;
    let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    let tmp_name = format!(
        ".{}.{}.{}.tmp",
        file_name.to_str().expect("validated as UTF-8 in from_uri"),
        std::process::id(),
        counter
    );
    let tmp_path = parent.join(&tmp_name);
    Ok((parent, tmp_path))
}

/// Rename `tmp_path` to `final_path`, then fsync the parent directory.
///
/// On failure the temp file is removed (best-effort) and `final_path` is left
/// unchanged.
fn rename_into_place(
    tmp_path: &std::path::Path,
    final_path: &std::path::Path,
    parent: &std::path::Path,
) -> std::io::Result<()> {
    if let Err(e) = std::fs::rename(tmp_path, final_path) {
        let _ = std::fs::remove_file(tmp_path);
        return Err(e);
    }
    // Fsync the parent directory so the new directory entry is durable.
    // Without this, a power loss after rename could leave the old entry
    // (or no entry) on recovery.  Best-effort: ignore errors from read-only
    // or virtual filesystems where directory fsync is unsupported.
    let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
    Ok(())
}

/// Write `data` to `path` atomically using a temp-file-then-rename pattern.
///
/// Directly writing with `O_TRUNC` is not atomic: if the process is killed or
/// the disk fills up after truncation but before `write_all` completes, `path`
/// is left empty — the original secret is irrecoverably lost.
///
/// Instead, we write to a hidden temp file **in the same directory** as `path`
/// (guaranteeing they are on the same filesystem), then call `rename()`.
/// POSIX `rename()` is atomic: the old `path` contents are visible until the
/// instant the rename commits, and the new contents appear all-at-once.
///
/// If any step fails the temp file is removed (best-effort) and `path` is
/// left unchanged.
#[cfg(unix)]
fn write_secret_file(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
    use std::os::unix::fs::OpenOptionsExt;

    let (parent, tmp_path) = make_temp_path(path)?;

    // Write secret bytes to the temp file.  Use create_new so the open fails
    // atomically if the temp path somehow already exists — guaranteeing that
    // 0o600 is always the file's creation mode, not inherited from a stale file.
    let write_result = std::fs::OpenOptions::new()
        .write(true)
        .create_new(true)
        .mode(0o600)
        .open(&tmp_path)
        .and_then(|mut f| {
            f.write_all(data)?;
            // fsync before rename so the data is durable on disk before the
            // directory entry is updated.  Without this, a power loss between
            // write_all and rename could leave a zero-length or partial file.
            f.sync_all()
        });

    if let Err(e) = write_result {
        let _ = std::fs::remove_file(&tmp_path);
        return Err(e);
    }

    rename_into_place(&tmp_path, path, &parent)
}

#[cfg(not(unix))]
fn write_secret_file(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
    // Same temp-then-rename pattern for atomicity on non-Unix platforms.
    // File permissions are not set here (Windows uses ACLs; configure them
    // separately if restrictive access is required).
    let (parent, tmp_path) = make_temp_path(path)?;

    // Write + fsync so data is durable before the rename. On non-Unix we
    // cannot set permissions atomically at creation time; Windows ACLs must
    // be configured separately (see module-level docs).
    let write_result = std::fs::File::create(&tmp_path).and_then(|mut f| {
        f.write_all(data)?;
        f.sync_all()
    });
    if let Err(e) = write_result {
        let _ = std::fs::remove_file(&tmp_path);
        return Err(e);
    }

    rename_into_place(&tmp_path, path, &parent)
}

inventory::submit!(secretx_core::BackendRegistration::new(
    "file",
    |uri: &secretx_core::SecretUri| {
        let b = FileBackend::from_parsed_uri(uri)?;
        Ok(Arc::new(b) as Arc<dyn secretx_core::SecretStore>)
    },
));

inventory::submit!(secretx_core::WritableBackendRegistration::new(
    "file",
    |uri: &secretx_core::SecretUri| {
        let b = FileBackend::from_parsed_uri(uri)?;
        Ok(Arc::new(b) as Arc<dyn secretx_core::WritableSecretStore>)
    },
));

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

    #[test]
    fn from_uri_absolute() {
        let b = FileBackend::from_uri("secretx:file:/etc/passwd").unwrap();
        assert_eq!(*b.path, PathBuf::from("/etc/passwd"));
    }

    #[test]
    fn from_uri_relative() {
        let b = FileBackend::from_uri("secretx:file:relative/path").unwrap();
        assert_eq!(*b.path, PathBuf::from("relative/path"));
    }

    #[test]
    fn from_uri_rejects_dotdot_relative() {
        // ../etc/passwd — must not be accepted
        assert!(matches!(
            FileBackend::from_uri("secretx:file:../etc/passwd"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_rejects_dotdot_embedded() {
        // config/../../etc/passwd — traversal in the middle of a path
        assert!(matches!(
            FileBackend::from_uri("secretx:file:config/../../etc/passwd"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_rejects_dotdot_absolute() {
        // //etc/secrets/../passwd — traversal in absolute path
        assert!(matches!(
            FileBackend::from_uri("secretx:file:/etc/secrets/../passwd"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_wrong_backend() {
        assert!(matches!(
            FileBackend::from_uri("secretx:env:X"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_missing_path() {
        assert!(matches!(
            FileBackend::from_uri("secretx:file"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_root_slash_rejected() {
        assert!(matches!(
            FileBackend::from_uri("secretx:file:/"),
            Err(SecretError::InvalidUri(_))
        ));
    }

    #[test]
    fn from_uri_trailing_slash_ok() {
        // "foo/" normalizes to "foo" which has a valid file_name.
        FileBackend::from_uri("secretx:file:foo/").unwrap();
    }

    #[tokio::test]
    async fn get_missing_file() {
        let b = FileBackend::from_uri("secretx:file:/tmp/secretx_test_surely_missing_xyzzy123")
            .unwrap();
        assert!(matches!(b.get().await, Err(SecretError::NotFound)));
    }

    #[tokio::test]
    async fn put_and_get_roundtrip() {
        let path = std::env::temp_dir().join(format!(
            "secretx_file_test_roundtrip_{}.bin",
            std::process::id()
        ));
        // Absolute path: path.display() starts with '/' on Unix.
        let uri = format!("secretx:file:{}", path.display());
        let b = FileBackend::from_uri(&uri).unwrap();

        b.put(SecretValue::new(b"hello secret".to_vec()))
            .await
            .unwrap();
        let v = b.get().await.unwrap();
        assert_eq!(v.as_bytes(), b"hello secret");

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn refresh_sees_external_write() {
        let path = std::env::temp_dir().join(format!(
            "secretx_file_test_refresh_{}.bin",
            std::process::id()
        ));
        std::fs::write(&path, b"initial").unwrap();

        let uri = format!("secretx:file:{}", path.display());
        let b = FileBackend::from_uri(&uri).unwrap();

        let v1 = b.get().await.unwrap();
        assert_eq!(v1.as_bytes(), b"initial");

        std::fs::write(&path, b"updated").unwrap();

        let v2 = b.refresh().await.unwrap();
        assert_eq!(v2.as_bytes(), b"updated");

        let _ = std::fs::remove_file(&path);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn put_creates_with_mode_0600() {
        use std::os::unix::fs::PermissionsExt;
        let path =
            std::env::temp_dir().join(format!("secretx_file_test_mode_{}.bin", std::process::id()));
        let uri = format!("secretx:file:{}", path.display());
        let b = FileBackend::from_uri(&uri).unwrap();

        b.put(SecretValue::new(b"secret".to_vec())).await.unwrap();

        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o600);

        let _ = std::fs::remove_file(&path);
    }

    #[tokio::test]
    async fn put_parent_missing() {
        let base = std::env::temp_dir().join(format!(
            "secretx_file_test_noparent_{}",
            std::process::id()
        ));
        // Ensure the base directory does not exist.
        let _ = std::fs::remove_dir_all(&base);
        let path = base.join("nested").join("secret.txt");
        let uri = format!("secretx:file:{}", path.display());
        let store = FileBackend::from_uri(&uri).unwrap();

        let err = store.put(SecretValue::new(b"hello".to_vec())).await;
        assert!(
            matches!(err, Err(SecretError::Backend { .. })),
            "expected Backend error for missing parent dir, got {err:?}"
        );

        // Clean up just in case.
        let _ = std::fs::remove_dir_all(&base);
    }

    #[cfg(unix)]
    #[tokio::test]
    async fn put_permission_denied() {
        use std::os::unix::fs::PermissionsExt;

        let dir = std::env::temp_dir().join(format!(
            "secretx_file_test_perms_{}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();

        let path = dir.join("secret.txt");
        // Create the file first so the path is valid, then lock down the dir.
        std::fs::write(&path, b"original").unwrap();
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o444)).unwrap();

        let uri = format!("secretx:file:{}", path.display());
        let store = FileBackend::from_uri(&uri).unwrap();

        let err = store.put(SecretValue::new(b"new-value".to_vec())).await;

        // Restore permissions before any assertions so cleanup always succeeds.
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();

        assert!(
            matches!(err, Err(SecretError::Backend { .. })),
            "expected Backend error for permission denied, got {err:?}"
        );

        let _ = std::fs::remove_dir_all(&dir);
    }
}
