//! `FilesRepo` — the metadata row (Postgres) + blob bytes (filesystem)
//! storage layer for the v1.1.5 `files::*` SDK.
//!
//! Unlike KV/docs, this repo owns BOTH halves of a file: the `files`
//! row (metadata + SHA-256 checksum) and the bytes on disk at
//!   `<root>/files/<app_id>/<collection>/<id[0:2]>/<id>`.
//! It owns both because the write must be atomic across them — a crash
//! mid-write must never leave a readable half-written file.
//!
//! ## Atomic write protocol (`create` / `update`)
//!   1. Validate (collection path-safety; caps live one layer up).
//!   2. `create_dir_all` the shard dir with `0o700`.
//!   3. SHA-256 the in-memory bytes (single pass) while writing to
//!      `<final>.tmp.<unique>`.
//!   4. `fsync` the temp file.
//!   5. `rename` temp → final (atomic on POSIX).
//!   6. `fsync` the parent dir (so the rename is durable).
//!   7. INSERT / UPDATE the DB row.
//!
//! A crash between 1–5 leaves an orphan `*.tmp.*` (never read). A crash
//! between 5–7 leaves a file with no row — never reachable via the SDK
//! (reads start from the row). Both are reclaimed by a future orphan
//! sweep (deferred to v1.1.6+; see HANDBACK §7).
//!
//! ## Atomic delete protocol
//!   1. SELECT + DELETE the row inside one transaction; commit.
//!   2. `unlink` the file (outside the tx). A failure here leaves an
//!      orphan; a failure before the commit changes nothing.
//!
//! ## Checksum-on-read
//! `get` reads the file, hashes it, and compares against the stored
//! checksum — returning `FilesError::Corrupted` (and logging the path
//! at error level) on a mismatch. It never auto-deletes; the operator
//! decides what to do with a metadata-vs-bytes divergence.

use std::env;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use async_trait::async_trait;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, FileMeta, FileUpdate, FilesListPage, NewFile};
use sha2::{Digest, Sha256};
use sqlx::PgPool;
use uuid::Uuid;

/// 100 MB default per-file cap.
pub const DEFAULT_MAX_FILE_SIZE_BYTES: usize = 100 * 1024 * 1024;
/// Default filesystem root (relative to the process CWD).
pub const DEFAULT_FILES_ROOT: &str = "./data";

const FILES_LIST_MAX_LIMIT: u32 = 1_000;
const FILES_LIST_DEFAULT_LIMIT: u32 = 100;

/// Monotonic counter feeding unique temp-file suffixes (combined with
/// the pid). Avoids `rand` in the storage layer per the brief.
static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);

#[derive(Debug, thiserror::Error)]
pub enum FilesRepoError {
    #[error("database error: {0}")]
    Db(#[from] sqlx::Error),

    #[error("filesystem error: {0}")]
    Io(String),

    #[error("invalid collection name: {0}")]
    InvalidCollection(String),

    /// The bytes on disk no longer match the stored checksum (or are
    /// missing entirely while the row persists).
    #[error("file content corrupted (checksum mismatch)")]
    Corrupted,

    #[error("invalid pagination cursor")]
    InvalidCursor,
}

/// Outbound-files tunables. Env-overridable following the same pattern
/// as `HttpConfig::from_env`.
#[derive(Debug, Clone)]
pub struct FilesConfig {
    pub root: PathBuf,
    pub max_file_size_bytes: usize,
}

impl FilesConfig {
    #[must_use]
    pub fn conservative() -> Self {
        Self {
            root: PathBuf::from(DEFAULT_FILES_ROOT),
            max_file_size_bytes: DEFAULT_MAX_FILE_SIZE_BYTES,
        }
    }

    #[must_use]
    pub fn from_env() -> Self {
        let mut c = Self::conservative();
        if let Ok(v) = env::var("PICLOUD_FILES_ROOT") {
            if !v.trim().is_empty() {
                c.root = PathBuf::from(v);
            }
        }
        if let Ok(v) = env::var("PICLOUD_FILES_MAX_FILE_SIZE_BYTES") {
            match v.parse::<usize>() {
                Ok(n) => c.max_file_size_bytes = n,
                Err(e) => {
                    tracing::warn!(error = %e, "ignoring invalid PICLOUD_FILES_MAX_FILE_SIZE_BYTES");
                }
            }
        }
        c
    }
}

impl Default for FilesConfig {
    fn default() -> Self {
        Self::conservative()
    }
}

/// The new+prior metadata returned from a successful `update`, so the
/// service can emit a `ServiceEvent` with the change-data-capture
/// surface (`old_payload`).
#[derive(Debug, Clone)]
pub struct FileUpdated {
    pub new: FileMeta,
    pub prev: FileMeta,
}

#[async_trait]
pub trait FilesRepo: Send + Sync {
    async fn create(
        &self,
        app_id: AppId,
        collection: &str,
        new: NewFile,
    ) -> Result<FileMeta, FilesRepoError>;

    async fn head(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<FileMeta>, FilesRepoError>;

    /// Reads + checksum-verifies the bytes. `Ok(None)` when no row
    /// exists; `Err(Corrupted)` when the row exists but the bytes are
    /// missing or mismatched.
    async fn get(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<Vec<u8>>, FilesRepoError>;

    /// `Ok(None)` when no row exists (the SDK turns this into
    /// `FilesError::NotFound`).
    async fn update(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
    ) -> Result<Option<FileUpdated>, FilesRepoError>;

    /// Returns the deleted row's metadata if present, `None` otherwise.
    async fn delete(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<FileMeta>, FilesRepoError>;

    async fn list(
        &self,
        app_id: AppId,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<FilesListPage, FilesRepoError>;
}

/// Filesystem-bytes + Postgres-metadata repo.
pub struct FsFilesRepo {
    pool: PgPool,
    config: FilesConfig,
}

impl FsFilesRepo {
    #[must_use]
    pub fn new(pool: PgPool, config: FilesConfig) -> Self {
        Self { pool, config }
    }

    /// Defensive path-component guard. The service already validates the
    /// collection at the SDK boundary; this is belt-and-suspenders so a
    /// future caller can't smuggle a traversal sequence onto disk.
    fn guard_collection(collection: &str) -> Result<(), FilesRepoError> {
        if collection.is_empty()
            || collection.contains('/')
            || collection.contains('\\')
            || collection.contains("..")
            || collection.contains('\0')
        {
            return Err(FilesRepoError::InvalidCollection(collection.to_string()));
        }
        Ok(())
    }
    fn write_atomic(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
        bytes: &[u8],
    ) -> Result<String, FilesRepoError> {
        write_atomic_at(
            &self.config.root,
            &app_owner_dir(app_id),
            collection,
            id,
            bytes,
        )
    }
}

/// The owner-relative subdirectory of an **app**'s blobs under `<root>/files/`:
/// just `<app_id>`. Group-shared blobs (§11.6) shard at `groups/<group_id>`
/// instead (see `group_files_repo::group_owner_dir`) — a disjoint subtree under
/// the same `files/` base, so the orphan sweeper covers both with one walk.
pub(crate) fn app_owner_dir(app_id: AppId) -> PathBuf {
    PathBuf::from(app_id.into_inner().to_string())
}

/// `<root>/files/<owner_rel>/<collection>/<id[0:2]>/`. `owner_rel` is built
/// by the caller from a server-generated id (`<app_id>` or `groups/<group_id>`)
/// — never user input — and `collection` is path-guarded one layer up, so no
/// component can escape the `files/` base.
pub(crate) fn shard_dir_at(
    root: &Path,
    owner_rel: &Path,
    collection: &str,
    id_str: &str,
) -> PathBuf {
    root.join("files")
        .join(owner_rel)
        .join(collection)
        .join(&id_str[..2])
}

pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf {
    let id_str = id.to_string();
    shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str)
}

/// Steps 2–6 of the atomic-write protocol. Returns the lowercase hex
/// SHA-256 of the bytes (computed in a single pass over the in-memory
/// buffer — the file is never re-read). `pub(crate)` + owner-relative so the
/// app and group-shared (§11.6) repos share this single source for the
/// security-sensitive write+checksum mechanics, unit-testable without a pool.
pub(crate) fn write_atomic_at(
    root: &Path,
    owner_rel: &Path,
    collection: &str,
    id: Uuid,
    bytes: &[u8],
) -> Result<String, FilesRepoError> {
    use std::io::Write as _;

    let id_str = id.to_string();
    let dir = shard_dir_at(root, owner_rel, collection, &id_str);
    create_dir_all_secure(&dir)?;

    // Single-pass checksum over the in-memory buffer.
    let mut hasher = Sha256::new();
    hasher.update(bytes);
    let checksum = hex_lower(&hasher.finalize());

    let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
    let tmp = dir.join(format!("{id_str}.tmp.{}-{seq}", std::process::id()));
    let final_path = dir.join(&id_str);

    {
        let mut f = std::fs::File::create(&tmp).map_err(io_err)?;
        f.write_all(bytes).map_err(io_err)?;
        f.sync_all().map_err(io_err)?; // fsync temp
    }
    std::fs::rename(&tmp, &final_path).map_err(io_err)?; // atomic
                                                         // fsync the parent dir so the rename is durable.
    if let Ok(dirf) = std::fs::File::open(&dir) {
        let _ = dirf.sync_all();
    }
    Ok(checksum)
}

/// Read + checksum-verify the bytes at the given path-set. Free
/// function mirror of the `get` read path. Returns `Corrupted` when the
/// bytes are missing or don't match `expected_checksum`. `pub(crate)` +
/// owner-relative — shared with the group-files (§11.6) repo.
pub(crate) fn read_verify_at(
    root: &Path,
    owner_rel: &Path,
    collection: &str,
    id: Uuid,
    expected_checksum: &str,
) -> Result<Vec<u8>, FilesRepoError> {
    let path = final_path_at(root, owner_rel, collection, id);
    let bytes = match std::fs::read(&path) {
        Ok(b) => b,
        Err(e) => {
            tracing::error!(
                path = %path.display(), error = %e,
                "files: row exists but bytes are unreadable — treating as corrupted"
            );
            return Err(FilesRepoError::Corrupted);
        }
    };
    let mut hasher = Sha256::new();
    hasher.update(&bytes);
    let actual = hex_lower(&hasher.finalize());
    if actual != expected_checksum {
        tracing::error!(
            path = %path.display(), expected = %expected_checksum, actual = %actual,
            "files: checksum mismatch on read — content corrupted"
        );
        return Err(FilesRepoError::Corrupted);
    }
    Ok(bytes)
}

#[async_trait]
impl FilesRepo for FsFilesRepo {
    async fn create(
        &self,
        app_id: AppId,
        collection: &str,
        new: NewFile,
    ) -> Result<FileMeta, FilesRepoError> {
        Self::guard_collection(collection)?;
        let id = Uuid::new_v4();
        let size = i64::try_from(new.data.len()).unwrap_or(i64::MAX);

        let checksum = self.write_atomic(app_id, collection, id, &new.data)?;

        let row: FileRow = sqlx::query_as(
            "INSERT INTO files \
                (app_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
             VALUES ($1, $2, $3, $4, $5, $6, $7) \
             RETURNING id, collection, name, content_type, size_bytes, \
                       checksum_sha256, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(collection)
        .bind(id)
        .bind(&new.name)
        .bind(&new.content_type)
        .bind(size)
        .bind(&checksum)
        .fetch_one(&self.pool)
        .await?;

        Ok(row.into_meta())
    }

    async fn head(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<FileMeta>, FilesRepoError> {
        Self::guard_collection(collection)?;
        let row: Option<FileRow> = sqlx::query_as(
            "SELECT id, collection, name, content_type, size_bytes, \
                    checksum_sha256, created_at, updated_at \
             FROM files WHERE app_id = $1 AND collection = $2 AND id = $3",
        )
        .bind(app_id.into_inner())
        .bind(collection)
        .bind(id)
        .fetch_optional(&self.pool)
        .await?;
        Ok(row.map(FileRow::into_meta))
    }

    async fn get(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<Vec<u8>>, FilesRepoError> {
        Self::guard_collection(collection)?;
        let row: Option<(String,)> = sqlx::query_as(
            "SELECT checksum_sha256 FROM files \
             WHERE app_id = $1 AND collection = $2 AND id = $3",
        )
        .bind(app_id.into_inner())
        .bind(collection)
        .bind(id)
        .fetch_optional(&self.pool)
        .await?;
        let Some((stored_checksum,)) = row else {
            return Ok(None);
        };
        let bytes = read_verify_at(
            &self.config.root,
            &app_owner_dir(app_id),
            collection,
            id,
            &stored_checksum,
        )?;
        Ok(Some(bytes))
    }

    async fn update(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
        upd: FileUpdate,
    ) -> Result<Option<FileUpdated>, FilesRepoError> {
        Self::guard_collection(collection)?;
        // Read the prior row first (existence check + CDC surface).
        let Some(prev) = self.head(app_id, collection, id).await? else {
            return Ok(None);
        };

        let size = i64::try_from(upd.data.len()).unwrap_or(i64::MAX);
        let checksum = self.write_atomic(app_id, collection, id, &upd.data)?;

        let row: FileRow = sqlx::query_as(
            "UPDATE files SET \
                name = COALESCE($4, name), \
                content_type = COALESCE($5, content_type), \
                size_bytes = $6, \
                checksum_sha256 = $7, \
                updated_at = NOW() \
             WHERE app_id = $1 AND collection = $2 AND id = $3 \
             RETURNING id, collection, name, content_type, size_bytes, \
                       checksum_sha256, created_at, updated_at",
        )
        .bind(app_id.into_inner())
        .bind(collection)
        .bind(id)
        .bind(upd.name.as_deref())
        .bind(upd.content_type.as_deref())
        .bind(size)
        .bind(&checksum)
        .fetch_one(&self.pool)
        .await?;

        Ok(Some(FileUpdated {
            new: row.into_meta(),
            prev,
        }))
    }

    async fn delete(
        &self,
        app_id: AppId,
        collection: &str,
        id: Uuid,
    ) -> Result<Option<FileMeta>, FilesRepoError> {
        Self::guard_collection(collection)?;
        // `DELETE ... RETURNING` is one statement, so the old SELECT-FOR-UPDATE
        // + DELETE pair is unnecessary. Unlink only AFTER the row is gone: the
        // reverse order would destroy the bytes of a row that a failure keeps.
        let meta = delete_meta_on(&self.pool, app_id, collection, id).await?;
        if meta.is_some() {
            unlink_blob(&self.config.root, &app_owner_dir(app_id), collection, id);
        }
        Ok(meta)
    }

    async fn list(
        &self,
        app_id: AppId,
        collection: &str,
        cursor: Option<&str>,
        limit: u32,
    ) -> Result<FilesListPage, FilesRepoError> {
        Self::guard_collection(collection)?;
        let limit = if limit == 0 {
            FILES_LIST_DEFAULT_LIMIT
        } else {
            limit.min(FILES_LIST_MAX_LIMIT)
        };
        let last_id = match cursor {
            Some(c) => Some(decode_cursor(c)?),
            None => None,
        };
        let take = i64::from(limit) + 1;
        let rows: Vec<FileRow> = sqlx::query_as(
            "SELECT id, collection, name, content_type, size_bytes, \
                    checksum_sha256, created_at, updated_at \
             FROM files \
             WHERE app_id = $1 AND collection = $2 \
               AND ($3::uuid IS NULL OR id > $3) \
             ORDER BY id ASC \
             LIMIT $4",
        )
        .bind(app_id.into_inner())
        .bind(collection)
        .bind(last_id)
        .bind(take)
        .fetch_all(&self.pool)
        .await?;

        let mut files: Vec<FileMeta> = rows.into_iter().map(FileRow::into_meta).collect();
        let next_cursor = if files.len() > limit as usize {
            files.truncate(limit as usize);
            files.last().map(|m| encode_cursor(m.id))
        } else {
            None
        };
        Ok(FilesListPage { files, next_cursor })
    }
}

// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------

fn io_err(e: std::io::Error) -> FilesRepoError {
    FilesRepoError::Io(e.to_string())
}

/// `create_dir_all` with `0o700` on the created tree (Unix). On other
/// platforms it falls back to the default permissions.
fn create_dir_all_secure(dir: &Path) -> Result<(), FilesRepoError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::DirBuilderExt as _;
        std::fs::DirBuilder::new()
            .recursive(true)
            .mode(0o700)
            .create(dir)
            .map_err(io_err)
    }
    #[cfg(not(unix))]
    {
        std::fs::create_dir_all(dir).map_err(io_err)
    }
}

fn hex_lower(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        use std::fmt::Write as _;
        let _ = write!(s, "{b:02x}");
    }
    s
}

pub(crate) fn encode_cursor(last_id: Uuid) -> String {
    URL_SAFE_NO_PAD.encode(last_id.to_string().as_bytes())
}

pub(crate) fn decode_cursor(cursor: &str) -> Result<Uuid, FilesRepoError> {
    let bytes = URL_SAFE_NO_PAD
        .decode(cursor)
        .map_err(|_| FilesRepoError::InvalidCursor)?;
    let s = String::from_utf8(bytes).map_err(|_| FilesRepoError::InvalidCursor)?;
    Uuid::parse_str(&s).map_err(|_| FilesRepoError::InvalidCursor)
}

#[derive(sqlx::FromRow)]
pub(crate) struct FileRow {
    id: Uuid,
    collection: String,
    name: String,
    content_type: String,
    size_bytes: i64,
    checksum_sha256: String,
    created_at: DateTime<Utc>,
    updated_at: DateTime<Utc>,
}

impl FileRow {
    pub(crate) fn into_meta(self) -> FileMeta {
        FileMeta {
            id: self.id,
            collection: self.collection,
            name: self.name,
            content_type: self.content_type,
            size: u64::try_from(self.size_bytes).unwrap_or(0),
            checksum: self.checksum_sha256,
            created_at: self.created_at,
            updated_at: self.updated_at,
        }
    }
}

// ----------------------------------------------------------------------------
// Connection-scoped metadata operations
//
// The BYTES are written to disk outside any transaction (they are not
// transactional and cannot be). What these give `crate::atomic_write` is a way
// to commit the metadata row and the trigger fan-out it produces TOGETHER, so a
// committed file row always has its event enqueued.
//
// A rollback after the bytes are on disk leaves an orphan blob. That hazard is
// not new — the pre-existing repo already wrote the blob, then inserted the row
// in a separate statement that could fail — and the writer now explicitly
// unlinks the blob on the rollback path, so the window is strictly smaller than
// it was.
// ----------------------------------------------------------------------------

/// The four metadata columns a blob write sets. Bundled so the `*_meta_on`
/// helpers take a target (owner, collection, id) plus one payload, rather than a
/// long positional tail it would be easy to transpose.
#[derive(Clone, Copy)]
pub(crate) struct MetaFields<'a> {
    pub name: &'a str,
    pub content_type: &'a str,
    pub size: i64,
    pub checksum: &'a str,
}

/// The same, for an update: `name`/`content_type` are `None` to keep the stored
/// value (`COALESCE`), while the bytes always change.
#[derive(Clone, Copy)]
pub(crate) struct MetaPatch<'a> {
    pub name: Option<&'a str>,
    pub content_type: Option<&'a str>,
    pub size: i64,
    pub checksum: &'a str,
}

const FILE_COLS: &str = "id, collection, name, content_type, size_bytes, \
                         checksum_sha256, created_at, updated_at";

pub(crate) async fn head_on<'c, E>(
    exec: E,
    app_id: AppId,
    collection: &str,
    id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: Option<FileRow> = sqlx::query_as(&format!(
        "SELECT {FILE_COLS} FROM files \
         WHERE app_id = $1 AND collection = $2 AND id = $3"
    ))
    .bind(app_id.into_inner())
    .bind(collection)
    .bind(id)
    .fetch_optional(exec)
    .await?;
    Ok(row.map(FileRow::into_meta))
}

pub(crate) async fn insert_meta_on<'c, E>(
    exec: E,
    app_id: AppId,
    collection: &str,
    id: Uuid,
    meta: MetaFields<'_>,
) -> Result<FileMeta, FilesRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: FileRow = sqlx::query_as(&format!(
        "INSERT INTO files \
            (app_id, collection, id, name, content_type, size_bytes, checksum_sha256) \
         VALUES ($1, $2, $3, $4, $5, $6, $7) \
         RETURNING {FILE_COLS}"
    ))
    .bind(app_id.into_inner())
    .bind(collection)
    .bind(id)
    .bind(meta.name)
    .bind(meta.content_type)
    .bind(meta.size)
    .bind(meta.checksum)
    .fetch_one(exec)
    .await?;
    Ok(row.into_meta())
}

/// `None` when the file row is gone (the caller maps that to `NotFound`).
pub(crate) async fn update_meta_on<'c, E>(
    exec: E,
    app_id: AppId,
    collection: &str,
    id: Uuid,
    meta: MetaPatch<'_>,
) -> Result<Option<FileMeta>, FilesRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: Option<FileRow> = sqlx::query_as(&format!(
        "UPDATE files SET \
            name = COALESCE($4, name), \
            content_type = COALESCE($5, content_type), \
            size_bytes = $6, \
            checksum_sha256 = $7, \
            updated_at = NOW() \
         WHERE app_id = $1 AND collection = $2 AND id = $3 \
         RETURNING {FILE_COLS}"
    ))
    .bind(app_id.into_inner())
    .bind(collection)
    .bind(id)
    .bind(meta.name)
    .bind(meta.content_type)
    .bind(meta.size)
    .bind(meta.checksum)
    .fetch_optional(exec)
    .await?;
    Ok(row.map(FileRow::into_meta))
}

/// `DELETE ... RETURNING` — one statement, so no `SELECT ... FOR UPDATE` dance.
/// The caller unlinks the bytes AFTER the transaction commits: unlinking first
/// would destroy the blob of a row that a rollback then keeps.
pub(crate) async fn delete_meta_on<'c, E>(
    exec: E,
    app_id: AppId,
    collection: &str,
    id: Uuid,
) -> Result<Option<FileMeta>, FilesRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let row: Option<FileRow> = sqlx::query_as(&format!(
        "DELETE FROM files \
         WHERE app_id = $1 AND collection = $2 AND id = $3 \
         RETURNING {FILE_COLS}"
    ))
    .bind(app_id.into_inner())
    .bind(collection)
    .bind(id)
    .fetch_optional(exec)
    .await?;
    Ok(row.map(FileRow::into_meta))
}

/// Best-effort unlink of a blob whose metadata write was rolled back (or whose
/// row was just deleted). A failure leaves an orphan — logged, never fatal.
pub(crate) fn unlink_blob(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) {
    let path = final_path_at(root, owner_rel, collection, id);
    if let Err(e) = std::fs::remove_file(&path) {
        if e.kind() != std::io::ErrorKind::NotFound {
            tracing::warn!(
                path = %path.display(), error = %e,
                "files: unlink failed (orphan blob left on disk)"
            );
        }
    }
}

/// The PROJECTED total stored bytes for the app AFTER this write — the current
/// SUM, minus the bytes of the file being replaced (`replacing = Some(id)` on
/// update, `None` on create), plus the incoming blob's. Backs the per-app disk
/// ceiling; the subtraction is what lets an update near the cap still go through.
pub(crate) async fn projected_total_bytes_on<'c, E>(
    exec: E,
    app_id: AppId,
    replacing: Option<Uuid>,
    incoming: i64,
) -> Result<u64, FilesRepoError>
where
    E: sqlx::PgExecutor<'c>,
{
    let (n,): (i64,) = sqlx::query_as(
        "SELECT ( \
            COALESCE((SELECT SUM(size_bytes) FROM files WHERE app_id = $1), 0) \
          - COALESCE((SELECT size_bytes FROM files WHERE app_id = $1 AND id = $2), 0) \
          + $3 \
         )::BIGINT",
    )
    .bind(app_id.into_inner())
    .bind(replacing)
    .bind(incoming)
    .fetch_one(exec)
    .await?;
    Ok(u64::try_from(n).unwrap_or(0))
}

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

    #[test]
    fn hex_lower_matches_known_sha256_vector() {
        // SHA-256("abc") — NIST known-answer vector.
        let mut h = Sha256::new();
        h.update(b"abc");
        assert_eq!(
            hex_lower(&h.finalize()),
            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
        );
    }

    #[test]
    fn hex_lower_of_empty_is_known_vector() {
        let mut h = Sha256::new();
        h.update(b"");
        assert_eq!(
            hex_lower(&h.finalize()),
            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
        );
    }

    #[test]
    fn cursor_round_trips() {
        let id = Uuid::new_v4();
        let enc = encode_cursor(id);
        assert_eq!(decode_cursor(&enc).unwrap(), id);
        assert!(matches!(
            decode_cursor("!!not-base64!!"),
            Err(FilesRepoError::InvalidCursor)
        ));
    }

    #[test]
    fn guard_collection_rejects_traversal() {
        assert!(FsFilesRepo::guard_collection("avatars").is_ok());
        assert!(FsFilesRepo::guard_collection("a/b").is_err());
        assert!(FsFilesRepo::guard_collection("..").is_err());
        assert!(FsFilesRepo::guard_collection("a..b").is_err());
        assert!(FsFilesRepo::guard_collection("").is_err());
        assert!(FsFilesRepo::guard_collection("a\0b").is_err());
    }

    #[test]
    fn config_from_env_defaults_are_conservative() {
        let c = FilesConfig::conservative();
        assert_eq!(c.max_file_size_bytes, DEFAULT_MAX_FILE_SIZE_BYTES);
        assert_eq!(c.root, PathBuf::from(DEFAULT_FILES_ROOT));
    }

    // ------------------------------------------------------------------
    // Tempdir-backed filesystem mechanics — exercise the atomic write,
    // single-pass checksum, and checksum-on-read tamper detection
    // without needing a Postgres pool.
    // ------------------------------------------------------------------

    use picloud_shared::AppId;

    /// Process-unique scratch dir under the system temp dir. Cleaned up
    /// by each test via `remove_dir_all`.
    fn unique_tmp_root() -> PathBuf {
        let seq = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("picloud-files-test-{}-{seq}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn write_atomic_then_read_verify_round_trips() {
        let root = unique_tmp_root();
        let app = AppId::new();
        let id = Uuid::new_v4();
        let bytes = b"hello picloud files".to_vec();

        let checksum = write_atomic_at(&root, &app_owner_dir(app), "avatars", id, &bytes).unwrap();
        // Single-pass checksum matches an independent hash of the bytes.
        let mut h = Sha256::new();
        h.update(&bytes);
        assert_eq!(checksum, hex_lower(&h.finalize()));

        let read = read_verify_at(&root, &app_owner_dir(app), "avatars", id, &checksum).unwrap();
        assert_eq!(read, bytes);

        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn read_verify_detects_tampering_as_corrupted() {
        let root = unique_tmp_root();
        let app = AppId::new();
        let id = Uuid::new_v4();
        let checksum = write_atomic_at(&root, &app_owner_dir(app), "c", id, b"original").unwrap();

        // Mutate the bytes behind the repo's back.
        let path = final_path_at(&root, &app_owner_dir(app), "c", id);
        std::fs::write(&path, b"tampered").unwrap();

        let err = read_verify_at(&root, &app_owner_dir(app), "c", id, &checksum).unwrap_err();
        assert!(matches!(err, FilesRepoError::Corrupted));

        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn read_verify_missing_bytes_is_corrupted() {
        let root = unique_tmp_root();
        let app = AppId::new();
        let id = Uuid::new_v4();
        // No write — the file never existed.
        let err = read_verify_at(&root, &app_owner_dir(app), "c", id, "deadbeef").unwrap_err();
        assert!(matches!(err, FilesRepoError::Corrupted));
        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn atomic_write_leaves_no_tmp_file_after_success() {
        let root = unique_tmp_root();
        let app = AppId::new();
        let id = Uuid::new_v4();
        write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();

        let id_str = id.to_string();
        let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
        let entries: Vec<_> = std::fs::read_dir(&dir)
            .unwrap()
            .filter_map(Result::ok)
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        // Exactly the final file is visible — no `*.tmp.*` orphan.
        assert_eq!(entries, vec![id_str]);
        assert!(!entries.iter().any(|n| n.contains(".tmp.")));

        std::fs::remove_dir_all(&root).ok();
    }

    #[test]
    fn id_shard_uses_first_two_chars() {
        let root = PathBuf::from("/tmp/x");
        let app = AppId::new();
        let id = Uuid::new_v4();
        let id_str = id.to_string();
        let path = final_path_at(&root, &app_owner_dir(app), "col", id);
        let shard = &id_str[..2];
        assert!(path
            .to_string_lossy()
            .contains(&format!("/col/{shard}/{id_str}")));
    }

    #[cfg(unix)]
    #[test]
    fn shard_tree_created_with_0700() {
        use std::os::unix::fs::PermissionsExt as _;
        let root = unique_tmp_root();
        let app = AppId::new();
        let id = Uuid::new_v4();
        write_atomic_at(&root, &app_owner_dir(app), "c", id, b"data").unwrap();
        let id_str = id.to_string();
        let dir = shard_dir_at(&root, &app_owner_dir(app), "c", &id_str);
        let mode = std::fs::metadata(&dir).unwrap().permissions().mode();
        assert_eq!(mode & 0o777, 0o700, "shard dir should be 0o700");
        std::fs::remove_dir_all(&root).ok();
    }
}
