use std::{
    fs::OpenOptions,
    path::{Path, PathBuf},
    sync::Arc,
};

use fs4::FileExt;

use super::{
    TidalDb,
    config::{Config, ConfigError, StorageMode},
    metrics::MetricsState,
    paths::Paths,
};
use crate::{load::RateLimiterConfig, ranking::profile::RankingProfile, schema::Schema};

/// Fluent builder for constructing a [`TidalDb`] instance.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use tidaldb::TidalDb;
///
/// // Ephemeral (in-memory) database — no filesystem access:
/// let db = TidalDb::builder().ephemeral().open()?;
/// db.health_check()?;
/// # Ok(())
/// # }
/// ```
///
/// ```no_run
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use tidaldb::TidalDb;
///
/// // Persistent database with an explicit data directory:
/// let db = TidalDb::builder()
///     .with_data_dir("/var/lib/tidaldb")
///     .open()?;
/// # Ok(())
/// # }
/// ```
pub struct TidalDbBuilder {
    config: Config,
    /// Address for the optional metrics HTTP server (e.g. "127.0.0.1:9090").
    /// Only present when the `metrics` feature is enabled -- the field is
    /// feature-gated so it does not exist (and is never dead) in default builds.
    #[cfg(feature = "metrics")]
    metrics_addr: Option<String>,
    /// Optional schema for signal types. When set, `open()` wires the storage
    /// engine, WAL, and signal ledger into the returned `TidalDb`.
    schema: Option<Schema>,
    /// Optional rate limiter configuration. When `None`, the default
    /// (unlimited) config is used.
    rate_limiter_config: Option<RateLimiterConfig>,
    /// Optional transport for replication.
    ///
    /// When set and `NodeRole::Follower`, the receiver is started automatically.
    /// When set and `NodeRole::Leader`, the WAL shipper is spawned automatically.
    transport: Option<std::sync::Arc<dyn crate::replication::Transport>>,
    /// Schema-defined profiles that override matching builtins.
    profiles: Vec<RankingProfile>,
}

impl TidalDbBuilder {
    /// Create a new builder with default (ephemeral) configuration.
    #[must_use]
    pub fn new() -> Self {
        Self {
            config: Config::default(),
            #[cfg(feature = "metrics")]
            metrics_addr: None,
            schema: None,
            rate_limiter_config: None,
            transport: None,
            profiles: Vec::new(),
        }
    }

    /// Attach a validated [`Schema`] to this builder.
    ///
    /// When a schema is provided, [`open`](Self::open) wires in the storage
    /// engine, write-ahead log, and signal ledger, enabling the full M1 API
    /// (`write_item`, `signal`, `read_decay_score`, etc.).
    ///
    /// Without a schema the database still opens in M0 compatibility mode
    /// and only `health_check` and metrics are available.
    #[must_use]
    pub fn with_schema(mut self, schema: Schema) -> Self {
        self.schema = Some(schema);
        self
    }

    /// Attach schema-defined ranking profiles that override matching builtins.
    ///
    /// Profiles provided here are registered via [`override_register`] after
    /// built-in profiles during [`open`](Self::open). This allows schema YAML
    /// to replace built-in profile definitions with deployment-specific signal
    /// names and tuning parameters.
    #[must_use]
    pub fn with_profiles(mut self, profiles: Vec<RankingProfile>) -> Self {
        self.profiles = profiles;
        self
    }

    /// Configure per-agent session rate limiting.
    ///
    /// When set, each `(agent_id, session_id)` pair gets a token bucket with
    /// the specified burst capacity and sustained rate. Without this, rate
    /// limiting is disabled (unlimited by default).
    ///
    /// Use [`RateLimiterConfig::limited`] to create a limiting configuration.
    #[must_use]
    pub const fn with_rate_limiter_config(mut self, config: RateLimiterConfig) -> Self {
        self.rate_limiter_config = Some(config);
        self
    }

    /// Configure this instance for distributed deployment.
    ///
    /// Not required for single-node embedded use. The default `NodeConfig`
    /// produces a single-node configuration identical to M0-M7 behavior.
    #[must_use]
    pub fn with_cluster(mut self, config: super::config::NodeConfig) -> Self {
        self.config.cluster = config;
        self
    }

    /// Attach a replication transport.
    ///
    /// When the node role is `Follower`, `open()` automatically calls
    /// `start_replication(transport)` after the database is constructed.
    /// When the role is `Leader`, `open()` spawns the WAL shipper thread
    /// using this transport.
    ///
    /// Not required for single-node (`NodeRole::Single`) deployments.
    #[must_use]
    pub fn with_transport(
        mut self,
        transport: std::sync::Arc<dyn crate::replication::Transport>,
    ) -> Self {
        self.transport = Some(transport);
        self
    }

    /// Switch to ephemeral (in-memory) mode, clearing any directory paths.
    ///
    /// This is the default mode. Calling this is only necessary to reset
    /// a builder that was previously configured for persistent mode.
    #[must_use]
    pub fn ephemeral(mut self) -> Self {
        self.config.mode = StorageMode::Ephemeral;
        self.config.data_dir = None;
        self.config.wal_dir = None;
        self.config.cache_dir = None;
        self
    }

    /// Switch to persistent mode with the given data directory.
    ///
    /// The directory must exist and be writable at the time [`open`](Self::open)
    /// is called. WAL and cache directories default to subdirectories of
    /// `data_dir` unless explicitly overridden.
    #[must_use]
    pub fn with_data_dir(mut self, dir: impl AsRef<Path>) -> Self {
        self.config.mode = StorageMode::Persistent;
        self.config.data_dir = Some(dir.as_ref().to_path_buf());
        self
    }

    /// Record a WAL directory override (defaults to `{data_dir}/wal`).
    ///
    /// The override is **validated** (must exist and be writable) and **honored**:
    /// the WAL lives at exactly this path. The engine resolves the WAL directory
    /// through a single source of truth ([`Config::resolved_wal_dir`]) that is
    /// threaded to both the WAL writer and every reader (online compaction,
    /// export, the checkpoint thread), so the writer and readers can never
    /// disagree about where the WAL lives.
    #[must_use]
    pub fn wal_dir(mut self, dir: impl AsRef<Path>) -> Self {
        self.config.wal_dir = Some(dir.as_ref().to_path_buf());
        self
    }

    /// Record a cache directory override (defaults to `{data_dir}/cache`).
    ///
    /// # Current limitation (W1)
    ///
    /// `cache_dir` is **reserved**: no engine component reads it yet (the
    /// materialized-view / secondary-index cache it is intended for is future
    /// work). Setting it is validated but otherwise a no-op today.
    #[must_use]
    pub fn cache_dir(mut self, dir: impl AsRef<Path>) -> Self {
        self.config.cache_dir = Some(dir.as_ref().to_path_buf());
        self
    }

    /// Eagerly validate the configuration.
    ///
    /// Checks:
    /// - Persistent mode requires `data_dir`
    /// - All specified directories must exist and be writable
    ///
    /// # Design: runtime validation, not typestate
    ///
    /// Required fields are enforced at `open`/`validate` time, returning a
    /// specific typed [`ConfigError`] per failure ([`ConfigError::MissingDataDir`],
    /// [`ConfigError::DirectoryNotFound`], [`ConfigError::NotWritable`]) -- never
    /// a panic or `unwrap`. A typestate builder (encoding "persistent implies
    /// `data_dir`" in the type system) was considered and rejected: the only
    /// conditionally-required field is `data_dir`, and its requirement depends on
    /// `mode`, which is itself toggled by [`ephemeral`](Self::ephemeral) /
    /// [`with_data_dir`](Self::with_data_dir). Splitting the builder into
    /// mode-specific typestates would ripple through every `builder().…().open()`
    /// call site in tidal-server, tidalctl, the examples, and the
    /// test suite for a low-severity ergonomics gain. Clear typed errors at the
    /// single `open` boundary are the proportionate fix.
    ///
    /// # Errors
    ///
    /// Returns [`ConfigError`] describing the first validation failure.
    pub fn validate(&self) -> Result<(), ConfigError> {
        if self.config.mode == StorageMode::Persistent && self.config.data_dir.is_none() {
            return Err(ConfigError::MissingDataDir);
        }

        // Validate each specified directory exists, is a directory, and is
        // writable. `data_dir` and `wal_dir` are the paths the engine actually
        // creates files under, so they get a real temp-file write probe (not a
        // permission-bit guess). `cache_dir` is reserved / no-op today, so it
        // only gets the cheaper shape checks.
        let dirs_to_check: [(Option<&PathBuf>, bool); 3] = [
            (self.config.data_dir.as_ref(), true),
            (self.config.wal_dir.as_ref(), true),
            (self.config.cache_dir.as_ref(), false),
        ];

        for (dir, probe_writable) in dirs_to_check {
            let Some(dir) = dir else { continue };
            if !dir.exists() {
                return Err(ConfigError::DirectoryNotFound { path: dir.clone() });
            }
            // Reject a path that exists but is not a directory (e.g. a regular
            // file or symlink to one). Without this, fjall/WAL fail later with
            // an opaque io error when they try to create children under it.
            if !dir.is_dir() {
                return Err(ConfigError::NotWritable { path: dir.clone() });
            }
            if probe_writable {
                // Real writability probe: `permissions().readonly()` reflects only
                // the inode's write bits, not whether THIS process's uid/gid can
                // write (a 0755 dir owned by another user reports !readonly yet
                // EACCES on create). Actually create+remove a uniquely-named file,
                // mirroring the lock-file open, so wrong-ownership and read-only
                // mounts are caught here at the open boundary, not opaquely during
                // component init.
                Self::probe_dir_writable(dir)?;
            } else {
                // Reserved path: a cheap permission-bit check is sufficient since
                // the engine does not write here today.
                if dir
                    .metadata()
                    .map(|m| m.permissions().readonly())
                    .unwrap_or(true)
                {
                    return Err(ConfigError::NotWritable { path: dir.clone() });
                }
            }
        }

        Ok(())
    }

    /// Probe that the current process can actually create (and remove) a file in
    /// `dir`, mapping any failure to [`ConfigError::NotWritable`].
    ///
    /// Uses a uniquely-named probe file (pid + nanosecond timestamp) created with
    /// `create_new` so concurrent probes never collide and a stale file is never
    /// silently reused, then removes it. This is the same "attempt a real write"
    /// approach the data-dir lock open relies on, and unlike a permission-bit
    /// check it catches the common wrong-ownership / read-only-mount cases.
    fn probe_dir_writable(dir: &Path) -> Result<(), ConfigError> {
        let unique = format!(
            ".tidaldb-writeprobe-{}-{}",
            std::process::id(),
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_nanos())
                .unwrap_or(0)
        );
        let probe_path = dir.join(unique);
        let create_result = OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&probe_path);
        match create_result {
            Ok(file) => {
                // Drop the handle before unlinking; ignore a remove error (the
                // create succeeded, which is all the probe needed to prove, and a
                // leftover probe file does not break correctness).
                drop(file);
                let _ = std::fs::remove_file(&probe_path);
                Ok(())
            }
            Err(_) => Err(ConfigError::NotWritable {
                path: dir.to_path_buf(),
            }),
        }
    }

    /// Enable the metrics HTTP server on the given address.
    ///
    /// Only available when the `metrics` feature is enabled. When the
    /// feature is disabled, this method does not exist and the builder
    /// compiles without it.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
    /// # #[cfg(feature = "metrics")]
    /// # {
    /// let db = tidaldb::TidalDb::builder()
    ///     .ephemeral()
    ///     .enable_metrics("127.0.0.1:9090")
    ///     .open()?;
    /// if let Some(addr) = db.metrics_addr() {
    ///     println!("metrics at http://{addr}/metrics");
    /// }
    /// # }
    /// # Ok(())
    /// # }
    /// ```
    #[cfg(feature = "metrics")]
    #[must_use]
    pub fn enable_metrics(mut self, addr: impl Into<String>) -> Self {
        self.metrics_addr = Some(addr.into());
        self
    }

    /// Resolve default directory paths using [`Paths`] for persistent mode.
    ///
    /// When a `data_dir` is set and `wal_dir` or `cache_dir` are not
    /// explicitly overridden, this fills them in from [`Paths`]. This
    /// makes `Paths` the single source of truth for directory layout --
    /// the builder and the CLI both derive defaults the same way.
    fn resolve_defaults(&mut self) {
        if let Some(ref data_dir) = self.config.data_dir {
            let paths = Paths::new(data_dir);
            if self.config.wal_dir.is_none() {
                self.config.wal_dir = Some(paths.wal_dir());
            }
            if self.config.cache_dir.is_none() {
                self.config.cache_dir = Some(paths.cache_dir());
            }
        }
    }

    /// Validate and open a [`TidalDb`] instance, returned **by value**.
    ///
    /// Calls [`validate`](Self::validate), then resolves default directory
    /// paths via [`Paths`], and constructs the database handle.
    ///
    /// Validation checks only user-specified directories. Resolved defaults
    /// (e.g., `{data_dir}/wal`) are populated after validation, then created by
    /// the components that own them during initialization: the WAL opens its own
    /// `{data_dir}/wal` directory ([`WalHandle::open`](crate::wal::WalHandle::open)
    /// calls `create_dir_all`), and fjall creates its keyspace files directly
    /// under `data_dir`. [`Paths::ensure_all`] is **not** part of this path — it
    /// is a CLI/test helper (see [`Paths`]), not an engine-open step.
    ///
    /// ## Session TTL sweeper
    ///
    /// The background session-TTL sweeper is **not** started by this method,
    /// because a by-value `TidalDb` has no stable self-address for the
    /// sweeper's `Weak` to track once the value is moved into its final home.
    /// For automatic session expiry in persistent deployments, use
    /// [`open_arc`](Self::open_arc), which starts the sweeper against the
    /// long-lived `Arc<TidalDb>` it returns. Embedded by-value callers drive
    /// expiry explicitly via `force_sweep()`.
    ///
    /// # Errors
    ///
    /// Returns [`crate::TidalError`] if validation fails or initialization
    /// encounters an error.
    #[tracing::instrument(skip(self), fields(mode = ?self.config.mode))]
    #[allow(clippy::too_many_lines)]
    pub fn open(mut self) -> crate::Result<TidalDb> {
        self.validate()?;

        // W43: validate the rate-limiter config eagerly. `RateLimiter::new`
        // (used downstream) silently falls back to *unlimited* on a degenerate
        // config (zero/NaN/negative rate, sub-unit burst), so a typo'd limit
        // would disable rate limiting with no signal to the operator. Surface it
        // here as a config error instead — the operator asked for a limit and
        // must learn at open() that the one they gave is unenforceable.
        if let Some(ref rl_config) = self.rate_limiter_config
            && let Err(e) = rl_config.validate()
        {
            return Err(crate::TidalError::invalid_input(format!(
                "invalid rate limiter config: {e}"
            )));
        }

        self.resolve_defaults();

        // ── Fix A: Acquire exclusive advisory lock on the data directory ────
        // Persistent mode only. Prevents two processes from silently
        // corrupting state by opening the same directory simultaneously.
        // The lock is held for the lifetime of the TidalDb instance and
        // released automatically when the File handle is dropped.
        let lock_file = if let Some(ref data_dir) = self.config.data_dir {
            let lock_path = data_dir.join("tidaldb.lock");
            let file = OpenOptions::new()
                .write(true)
                .create(true)
                .truncate(false)
                .open(&lock_path)
                .map_err(|e| {
                    crate::TidalError::internal(
                        "open",
                        format!("failed to open lock file {}: {e}", lock_path.display()),
                    )
                })?;

            // `try_lock_exclusive` returns `WouldBlock` when another process
            // already holds the lock, but any other I/O errno (EBADF, EINTR,
            // EIO, …) is a genuine filesystem failure. Mapping *every* error to
            // `DataDirLocked` mislabels a failing disk as a concurrent-open
            // conflict; distinguish the two so the operator gets the real cause.
            file.try_lock_exclusive().map_err(|e| {
                if e.kind() == std::io::ErrorKind::WouldBlock {
                    crate::TidalError::Config(ConfigError::DataDirLocked {
                        path: data_dir.clone(),
                    })
                } else {
                    crate::TidalError::internal(
                        "open",
                        format!(
                            "failed to acquire data-dir lock on {}: {e}",
                            lock_path.display()
                        ),
                    )
                }
            })?;

            Some(file)
        } else {
            None
        };

        let metrics = Arc::new(MetricsState::new());

        if let Some(schema) = self.schema {
            // Wire in storage, WAL, signal ledger, and M2 indexes.
            let result = TidalDb::open_with_schema(&self.config, schema, self.profiles)?;

            // ── Fix B: Schema fingerprint persistence ───────────────────
            // Persistent mode only. Compute a BLAKE3 hash of the schema's
            // signal definitions (sorted by name, hashing half_life as
            // u128 nanos to avoid float instability). On first open the
            // fingerprint is written; on subsequent opens it is compared
            // against the stored value.
            if self.config.mode == StorageMode::Persistent {
                super::schema_fingerprint::verify_or_store_schema_fingerprint(
                    &result.schema_def,
                    result.storage.items_engine(),
                )?;
            }

            let mut db = TidalDb::from_parts(self.config, metrics, result);

            // Start the optional metrics HTTP server now that the control
            // plane is installed on the metrics state. Done after construction
            // (not before) so the metrics Arc is uniquely owned when
            // `from_parts` wires the control plane via `Arc::get_mut`.
            #[cfg(feature = "metrics")]
            if let Some(ref addr) = self.metrics_addr {
                let handle = super::http::MetricsHandle::start(addr, Arc::clone(db.metrics()))
                    .map_err(|e| {
                        crate::TidalError::internal(
                            "open",
                            format!("metrics server failed to start: {e}"),
                        )
                    })?;
                db.set_metrics_handle(handle);
            }

            // Transfer the lock file into the TidalDb so it lives for
            // the duration of the database instance.
            if let Some(lf) = lock_file {
                db.set_lock_file(lf);
            }

            if let Some(rl_config) = self.rate_limiter_config {
                db = db.with_rate_limiter_config(rl_config);
            }

            // Restore previously archived session snapshots from storage.
            db.restore_sessions();

            // Wire replication transport based on node role.
            if let Some(transport) = self.transport {
                use super::config::NodeRole;
                match db.config.cluster.role {
                    NodeRole::Follower => {
                        // Start the segment receiver thread.
                        db.start_replication(transport).map_err(|e| {
                            crate::TidalError::internal(
                                "open",
                                format!("failed to start replication receiver: {e}"),
                            )
                        })?;
                        tracing::info!(
                            shard = %db.config.cluster.shard_id,
                            "follower: replication receiver started"
                        );
                    }
                    NodeRole::Leader => {
                        // Spawn the WAL shipper thread (persistent mode only: needs WAL dir).
                        if let Some(ref wal_dir) = db.config.wal_dir {
                            let shipper_config = crate::replication::ShipperConfig {
                                wal_dir: wal_dir.clone(),
                                shard_id: db.config.cluster.shard_id,
                                peer_shards: db.config.cluster.peer_shards.clone(),
                                poll_interval: std::time::Duration::from_secs(2),
                                // Full replica replication: ship every event,
                                // including local-scope. Community overlays use a
                                // separate filtered shipper (M9p2+).
                                community_share_only: false,
                            };
                            let handle =
                                crate::replication::spawn_shipper(shipper_config, transport);
                            db.shipper_handle = Some(handle);
                            tracing::info!(
                                shard = %db.config.cluster.shard_id,
                                peers = ?db.config.cluster.peer_shards,
                                "leader: WAL shipper started"
                            );
                        } else {
                            tracing::warn!(
                                "leader role configured but no WAL dir available; \
                                 shipper not started (ephemeral mode)"
                            );
                        }
                    }
                    NodeRole::Single => {
                        // Single-node: no replication threads needed.
                        tracing::debug!("transport provided but NodeRole::Single; ignored");
                    }
                }
            }

            // NOTE: the session TTL sweeper is NOT started here. The previous
            // implementation wrapped `db` in a temporary `Arc`, called
            // `start_sweeper` (which downgrades to `Weak`), then immediately
            // `Arc::try_unwrap`'d the db back out -- destroying the only strong
            // `Arc` the sweeper's `Weak` pointed at. The thread's first
            // `Weak::upgrade()` (60s later) therefore always failed and the
            // sweeper was dead-on-arrival (CONCURRENCY-1). A `TidalDb` returned
            // by value has no stable self-address for the sweeper to track.
            //
            // The sweeper now runs only via [`open_arc`](Self::open_arc), which
            // starts it against the final long-lived `Arc<TidalDb>` the caller
            // keeps. Embedded by-value callers drive expiry with `force_sweep()`.

            tracing::info!(
                mode = ?db.config.mode,
                data_dir = ?db.config.data_dir,
                items = db.item_count(),
                "tidaldb opened with schema"
            );

            Ok(db)
        } else {
            // M0 compatibility mode: no storage, no ledger, no WAL.
            let mut db = TidalDb::from_config(self.config, metrics);

            // Start the optional metrics HTTP server after construction so the
            // control plane is already installed on the uniquely owned metrics
            // state (see the schema branch for the ordering rationale).
            #[cfg(feature = "metrics")]
            if let Some(ref addr) = self.metrics_addr {
                let handle = super::http::MetricsHandle::start(addr, Arc::clone(db.metrics()))
                    .map_err(|e| {
                        crate::TidalError::internal(
                            "open",
                            format!("metrics server failed to start: {e}"),
                        )
                    })?;
                db.set_metrics_handle(handle);
            }

            if let Some(lf) = lock_file {
                db.set_lock_file(lf);
            }

            if let Some(rl_config) = self.rate_limiter_config {
                db = db.with_rate_limiter_config(rl_config);
            }

            tracing::info!(mode = ?db.config.mode, "tidaldb opened (no schema)");

            Ok(db)
        }
    }

    /// Validate and open a [`TidalDb`] wrapped in a long-lived `Arc`, with the
    /// session-TTL sweeper started against that final handle.
    ///
    /// This is the sanctioned path for persistent deployments that want
    /// automatic session expiry: the sweeper holds a `Weak<TidalDb>` whose
    /// upgrade target is the returned `Arc`, so it stays alive for the
    /// database's whole lifetime and shuts down within ~1s of `Arc` drop or an
    /// explicit `close()`. (Plain [`open`](Self::open) cannot start the sweeper
    /// because a by-value `TidalDb` has no stable address — CONCURRENCY-1.)
    ///
    /// In ephemeral mode no sweeper is needed (sessions are never persisted),
    /// so this simply returns the `Arc` without spawning a thread.
    ///
    /// # Errors
    ///
    /// Returns [`crate::TidalError`] if validation fails or initialization
    /// encounters an error.
    pub fn open_arc(self) -> crate::Result<Arc<TidalDb>> {
        let db = Arc::new(self.open()?);
        // `start_sweeper` is a no-op in ephemeral mode (sessions never persist),
        // so it is safe to call unconditionally against the long-lived Arc.
        TidalDb::start_sweeper(&db);
        Ok(db)
    }
}

impl Default for TidalDbBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
#[path = "builder_tests.rs"]
mod tests;

#[cfg(test)]
mod rate_limiter_validation_tests {
    use super::*;
    use crate::load::RateLimiterConfig;

    /// W43: a degenerate rate-limiter config (zero rate, which can never refill
    /// a bucket) must be rejected at `open()` rather than silently degrading to
    /// unlimited via `RateLimiter::new`'s fallback.
    #[test]
    fn open_rejects_degenerate_rate_limiter_config() {
        let result = TidalDb::builder()
            .ephemeral()
            .with_rate_limiter_config(RateLimiterConfig::limited(0.0, 10.0))
            .open();
        let err = result.expect_err("zero-rate limiter config must be rejected");
        let msg = err.to_string();
        assert!(
            msg.contains("rate limiter"),
            "error must name the rate limiter config; got: {msg}"
        );
    }

    /// A genuinely limiting (valid) config still opens — validation only rejects
    /// the unenforceable degenerate cases.
    #[test]
    fn open_accepts_valid_rate_limiter_config() {
        let result = TidalDb::builder()
            .ephemeral()
            .with_rate_limiter_config(RateLimiterConfig::limited(100.0, 200.0))
            .open();
        assert!(result.is_ok(), "valid rate-limiter config must open");
    }
}

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

    /// W3: a path that exists but is a regular file (not a directory) must be
    /// rejected at `validate()`/`open()` with a typed `ConfigError`, not pass and
    /// fail opaquely later when fjall/WAL try to create children under it.
    #[test]
    fn validate_rejects_regular_file_as_data_dir() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let file_path = tmp.path().join("not-a-dir");
        std::fs::write(&file_path, b"x").expect("write probe file");

        let builder = TidalDb::builder().with_data_dir(&file_path);
        let err = builder
            .validate()
            .expect_err("a regular file must not validate as a data_dir");
        assert!(
            matches!(err, ConfigError::NotWritable { .. }),
            "expected NotWritable for a non-directory, got: {err:?}"
        );
        // And the full open path must surface the same typed error.
        assert!(
            TidalDb::builder().with_data_dir(&file_path).open().is_err(),
            "open must reject a non-directory data_dir"
        );
    }

    /// W4: writability is probed with a real temp-file create, so a directory the
    /// process cannot write fails `validate()` with `NotWritable` rather than
    /// passing on a misleading permission bit.
    #[cfg(unix)]
    #[test]
    fn validate_rejects_unwritable_data_dir() {
        use std::os::unix::fs::PermissionsExt;

        let tmp = tempfile::tempdir().expect("tempdir");
        let dir = tmp.path().join("readonly");
        std::fs::create_dir(&dir).expect("create dir");
        // 0555: readable + executable, NOT writable for the owner.
        std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555))
            .expect("set readonly perms");

        // Running as root bypasses DAC write checks, so the probe would succeed
        // and the assertion would be meaningless. Detect that by directly
        // probing the dir the same way validate() does; if the write succeeds
        // here, we are root (or on an fs that ignores the bits) — skip.
        let direct_probe = std::fs::OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(dir.join(".tidaldb-roottest"));
        if let Ok(f) = direct_probe {
            drop(f);
            let _ = std::fs::remove_file(dir.join(".tidaldb-roottest"));
            let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));
            return;
        }

        let builder = TidalDb::builder().with_data_dir(&dir);
        let result = builder.validate();

        // Restore write perms so the tempdir can be cleaned up regardless.
        let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755));

        let err = result.expect_err("an unwritable data_dir must not validate");
        assert!(
            matches!(err, ConfigError::NotWritable { .. }),
            "expected NotWritable for an unwritable dir, got: {err:?}"
        );
    }

    /// A normal writable directory still validates and opens — the probe only
    /// rejects genuinely unusable paths.
    #[test]
    fn validate_accepts_writable_dir_and_leaves_no_probe_file() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let builder = TidalDb::builder().with_data_dir(tmp.path());
        builder.validate().expect("writable dir must validate");

        // The probe must clean up after itself: no leftover write-probe files.
        let leftovers: Vec<_> = std::fs::read_dir(tmp.path())
            .expect("read tempdir")
            .filter_map(Result::ok)
            .filter(|e| {
                e.file_name()
                    .to_string_lossy()
                    .starts_with(".tidaldb-writeprobe-")
            })
            .collect();
        assert!(
            leftovers.is_empty(),
            "write probe must remove its temp file; found {} leftover(s)",
            leftovers.len()
        );
    }
}
