//! Project lifecycle: data dir resolution, project creation,
//! project opening, lock-file ownership.
//!
//! This module is the home of the in-memory representation of
//! a project on disk. ADR-0015 is the spec; the iteration that
//! introduced this module (Iteration 1) builds the directory
//! skeleton, the file-backed SQLite database, the lock file,
//! and the display-name plumbing. Per-command persistence to
//! YAML / CSV / `history.log` lands in Iteration 2.
//!
//! Nothing here touches Tokio. Project creation and opening
//! are sync filesystem operations; the runtime calls them once
//! at startup and once per `load`/`new`/`save as`.

use std::fs;
use std::path::{Path, PathBuf};

use directories::ProjectDirs;
use tracing::{debug, info};

pub mod lock;
pub mod naming;
pub mod prettifier;

use lock::{Lock, LockError};
use naming::NamingError;

/// File and directory names inside a project. Public so other
/// modules (db, runtime, future iterations) can reference them
/// without re-deriving paths.
pub const PROJECT_YAML: &str = "project.yaml";
pub const DATA_DIR: &str = "data";
pub const HISTORY_LOG: &str = "history.log";
pub const PLAYGROUND_DB: &str = "playground.db";
pub const GITIGNORE: &str = ".gitignore";

/// Sub-directory of the data root that holds projects.
pub const PROJECTS_SUBDIR: &str = "projects";

/// State file under the data root used by `--resume`.
///
/// Records the absolute path of the most-recently-opened
/// project (Iteration 6, ADR-0015 §7). The runtime writes
/// it on every successful project open and reads it when
/// `--resume` is passed; a clean exit deliberately leaves
/// it intact (the whole point is to reopen "what I had").
pub const LAST_PROJECT_FILE: &str = "last_project";

/// Read the recorded last-project path under `data_root`,
/// stripping trailing whitespace/newlines.
///
/// Returns `Ok(None)` when the file is absent (a fresh data
/// root), `Err(_)` for IO errors that aren't `NotFound`. The
/// runtime treats `None` as "no resume target" and surfaces
/// the absent path explicitly when `--resume` was requested.
pub fn read_last_project(data_root: &Path) -> std::io::Result<Option<PathBuf>> {
    let path = data_root.join(LAST_PROJECT_FILE);
    match fs::read_to_string(&path) {
        Ok(body) => {
            let trimmed = body.trim();
            if trimmed.is_empty() {
                Ok(None)
            } else {
                Ok(Some(PathBuf::from(trimmed)))
            }
        }
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(e),
    }
}

/// Atomically write `project_path` as the recorded
/// last-project for `data_root` (uses temp-write + rename so
/// a crash mid-write never leaves a half-line behind).
///
/// The path is written verbatim, with a single trailing
/// newline. We don't canonicalize: a stale entry pointing at
/// a moved/deleted directory is the kind of error `--resume`
/// is supposed to surface clearly, not paper over by
/// resolving symlinks at write time.
pub fn write_last_project(data_root: &Path, project_path: &Path) -> std::io::Result<()> {
    fs::create_dir_all(data_root)?;
    let final_path = data_root.join(LAST_PROJECT_FILE);
    let tmp_path = data_root.join(format!("{LAST_PROJECT_FILE}.tmp"));
    {
        use std::io::Write as _;
        let mut f = fs::File::create(&tmp_path)?;
        writeln!(f, "{}", project_path.display())?;
        f.sync_all()?;
    }
    fs::rename(&tmp_path, &final_path)?;
    Ok(())
}

/// Resolve the data root for this run.
///
/// - If `override_dir` is `Some`, that path is used verbatim
///   (CLI `--data-dir`, ADR-0015 §1).
/// - Otherwise the OS-standard application data directory is
///   used (Linux: `$XDG_DATA_HOME/rdbms-playground` or
///   `~/.local/share/rdbms-playground`; macOS:
///   `~/Library/Application Support/rdbms-playground`;
///   Windows: `%APPDATA%\rdbms-playground`).
pub fn resolve_data_root(override_dir: Option<&Path>) -> Result<PathBuf, ProjectError> {
    if let Some(p) = override_dir {
        return Ok(p.to_path_buf());
    }
    let dirs =
        ProjectDirs::from("", "", "rdbms-playground").ok_or(ProjectError::DataRootUnavailable)?;
    Ok(dirs.data_dir().to_path_buf())
}

/// `<data-root>/projects`. Created on demand.
#[must_use]
pub fn projects_dir(data_root: &Path) -> PathBuf {
    data_root.join(PROJECTS_SUBDIR)
}

/// One entry the load picker shows. Contains the full path
/// (so the runtime can switch to it) plus the cached display
/// metadata (so the picker doesn't have to re-prettify on
/// every redraw).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectListing {
    pub path: PathBuf,
    pub display_name: String,
    /// The `mtime` of `project.yaml`, formatted as
    /// `YYYY-MM-DD HH:MM` for display in the picker. Falls
    /// back to "" if the metadata can't be read.
    pub modified: String,
    pub kind: ProjectKind,
}

/// List the projects available to the load picker.
///
/// Walks `<data_root>/projects/` and returns one
/// `ProjectListing` per child directory that looks like a
/// project (has a `project.yaml`). Sorted newest-first by
/// `project.yaml` mtime.
pub fn list_projects(data_root: &Path) -> Vec<ProjectListing> {
    let dir = projects_dir(data_root);
    let Ok(entries) = fs::read_dir(&dir) else {
        return Vec::new();
    };
    let mut listings: Vec<(std::time::SystemTime, ProjectListing)> = Vec::new();
    for entry in entries.flatten() {
        let path = entry.path();
        let yaml = path.join(PROJECT_YAML);
        if !yaml.exists() {
            continue;
        }
        let mtime = yaml
            .metadata()
            .and_then(|m| m.modified())
            .unwrap_or(std::time::UNIX_EPOCH);
        let dirname = directory_name(&path);
        let display_name = prettifier::prettify(&dirname);
        let modified = format_modified(mtime);
        let kind = if naming::is_temp_dirname(&dirname) {
            ProjectKind::Temp
        } else {
            ProjectKind::Named
        };
        listings.push((
            mtime,
            ProjectListing {
                path,
                display_name,
                modified,
                kind,
            },
        ));
    }
    // Sort newest mtime first.
    listings.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
    listings.into_iter().map(|(_, l)| l).collect()
}

fn format_modified(t: std::time::SystemTime) -> String {
    let secs = t
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    let day_secs = secs.rem_euclid(86_400);
    let h = day_secs / 3600;
    let m = (day_secs % 3600) / 60;
    let (y, mo, d) = naming_ymd(secs);
    format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
}

/// Iteration-1 startup logic (ADR-0015 §1):
///
/// - If `project_path` is `Some`, open that project (refused if
///   it doesn't exist or doesn't look like one).
/// - Otherwise create a fresh auto-named temp project under the
///   active data root, resolved from `data_dir_override` plus
///   the OS-standard fallback.
///
/// Splits cleanly out of `runtime::run` so the same logic is
/// reachable from integration tests without booting a Tokio
/// runtime or a terminal.
pub fn open_or_create(
    project_path: Option<&Path>,
    data_dir_override: Option<&Path>,
) -> Result<Project, ProjectError> {
    if let Some(path) = project_path {
        Project::open(path)
    } else {
        let data_root = resolve_data_root(data_dir_override)?;
        Project::create_temp(&data_root)
    }
}

/// An opened project. Holds the lock for its lifetime.
#[derive(Debug)]
pub struct Project {
    path: PathBuf,
    display_name: String,
    kind: ProjectKind,
    /// Held for the project's lifetime; released on drop.
    _lock: Lock,
}

/// Whether this project is auto-named temporary or
/// user-named permanent (ADR-0015 §1, §11).
///
/// The distinction drives the `save` command's behaviour:
/// for temp projects it elevates to a named project (== `save
/// as`); for named projects it reports "already auto-saved"
/// since no work has been done that wasn't already persisted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProjectKind {
    Temp,
    Named,
}

#[derive(Debug)]
pub enum ProjectError {
    DataRootUnavailable,
    PathNotFound(PathBuf),
    NotAProject(PathBuf),
    AlreadyExists(PathBuf),
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
    Naming(NamingError),
    Lock(LockError),
}

impl std::fmt::Display for ProjectError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::DataRootUnavailable => f.write_str(&crate::t!("project.data_root_unavailable")),
            Self::PathNotFound(p) => {
                f.write_str(&crate::t!("project.path_not_found", path = p.display(),))
            }
            Self::NotAProject(p) => {
                f.write_str(&crate::t!("project.not_a_project", path = p.display(),))
            }
            Self::AlreadyExists(p) => {
                f.write_str(&crate::t!("project.already_exists", path = p.display(),))
            }
            Self::Io { path, source } => f.write_str(&crate::t!(
                "project.io",
                path = path.display(),
                source = source,
            )),
            // Naming and Lock are transparent — their own Display
            // impls already route through the catalog.
            Self::Naming(inner) => std::fmt::Display::fmt(inner, f),
            Self::Lock(inner) => std::fmt::Display::fmt(inner, f),
        }
    }
}

impl std::error::Error for ProjectError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            Self::Naming(inner) => Some(inner),
            Self::Lock(inner) => Some(inner),
            _ => None,
        }
    }
}

impl From<NamingError> for ProjectError {
    fn from(e: NamingError) -> Self {
        Self::Naming(e)
    }
}

impl From<LockError> for ProjectError {
    fn from(e: LockError) -> Self {
        Self::Lock(e)
    }
}

impl Project {
    /// Create a new auto-named temp project under
    /// `<data-root>/projects/` and acquire its lock.
    ///
    /// The data root is created on demand (parent dirs included).
    /// The slug is checked for collisions; the project directory
    /// has its skeleton populated (an empty `project.yaml` with
    /// just `version` + `created_at`, an empty `data/`, an empty
    /// `history.log`, and a `.gitignore` template).
    pub fn create_temp(data_root: &Path) -> Result<Self, ProjectError> {
        let parent = projects_dir(data_root);
        ensure_dir(&parent)?;

        let mut rng = rand::rng();
        let slug = naming::generate_temp_name(&mut rng, &parent, naming::today_local)?;
        let path = parent.join(&slug);

        Self::initialize_skeleton(&path)?;
        let display_name = prettifier::prettify(&slug);
        let lock = Lock::acquire(&path)?;
        info!(path = %path.display(), name = %display_name, "created temp project");
        Ok(Self {
            path,
            display_name,
            kind: ProjectKind::Temp,
            _lock: lock,
        })
    }

    /// Create a *named* project at the chosen path. Refuses if
    /// the path already exists (any kind of entry — directory,
    /// file, symlink). The user should pick a different name or
    /// remove the existing entry first (ADR-0015 §2).
    ///
    /// The skeleton is initialized exactly like
    /// `create_temp`. The display name is the prettified
    /// directory name.
    pub fn create_named(path: &Path) -> Result<Self, ProjectError> {
        if path.exists() {
            return Err(ProjectError::AlreadyExists(path.to_path_buf()));
        }
        Self::initialize_skeleton(path)?;
        let dirname = directory_name(path);
        let display_name = prettifier::prettify(&dirname);
        let lock = Lock::acquire(path)?;
        info!(path = %path.display(), name = %display_name, "created named project");
        Ok(Self {
            path: path.to_path_buf(),
            display_name,
            kind: ProjectKind::Named,
            _lock: lock,
        })
    }

    /// Open an existing project at `path`. Refuses if the path
    /// does not exist or does not look like a project (no
    /// `project.yaml` and no `playground.db` present).
    ///
    /// Acquires the lock. The display name is the prettified
    /// directory name.
    pub fn open(path: &Path) -> Result<Self, ProjectError> {
        if !path.exists() {
            return Err(ProjectError::PathNotFound(path.to_path_buf()));
        }
        if !looks_like_project(path) {
            return Err(ProjectError::NotAProject(path.to_path_buf()));
        }
        let dirname = directory_name(path);
        let display_name = prettifier::prettify(&dirname);
        let kind = if naming::is_temp_dirname(&dirname) {
            ProjectKind::Temp
        } else {
            ProjectKind::Named
        };
        let lock = Lock::acquire(path)?;
        info!(path = %path.display(), name = %display_name, "opened project");
        Ok(Self {
            path: path.to_path_buf(),
            display_name,
            kind,
            _lock: lock,
        })
    }

    /// Build the on-disk skeleton for a fresh project: the
    /// directory itself, an empty `data/`, an empty
    /// `history.log`, a placeholder `project.yaml` with just
    /// the current schema `version` and `created_at`, and a `.gitignore`.
    ///
    /// `playground.db` is not created here; it's created the
    /// first time `Database::open` runs against the path
    /// (sqlite creates the file on connect).
    fn initialize_skeleton(path: &Path) -> Result<(), ProjectError> {
        ensure_dir(path)?;
        ensure_dir(&path.join(DATA_DIR))?;

        // History log: empty file is fine.
        write_if_missing(&path.join(HISTORY_LOG), "")?;

        // project.yaml: minimal placeholder. Iteration 2 will
        // actually populate `tables` / `relationships` on every
        // schema mutation; for now we just ensure the file
        // exists and carries the version + creation timestamp.
        let yaml = format!(
            "version: {}\nproject:\n  created_at: {}\ntables: []\nrelationships: []\n",
            crate::persistence::migrations::CURRENT_SCHEMA_VERSION,
            iso8601_now(),
        );
        write_if_missing(&path.join(PROJECT_YAML), &yaml)?;

        // .gitignore template (ADR-0015 §11). Excludes the
        // derived `.db`, the per-process lock, migration backups,
        // and the undo snapshot ring (ADR-0006 Amendment 1 — local
        // working state). `history.log` is intentionally NOT ignored
        // (ADR-0007 amendment 1: per-user choice).
        let gitignore =
            "/playground.db\n/.rdbms-playground.lock\n/project.yaml.v*.bak\n/.snapshots/\n";
        write_if_missing(&path.join(GITIGNORE), gitignore)?;

        Ok(())
    }

    #[must_use]
    pub fn path(&self) -> &Path {
        &self.path
    }

    #[must_use]
    pub fn display_name(&self) -> &str {
        &self.display_name
    }

    #[must_use]
    pub const fn kind(&self) -> ProjectKind {
        self.kind
    }

    /// Is this an auto-named temp project that the user has
    /// not modified?
    ///
    /// Used by `safely_delete_temp_project` to clean up the
    /// inevitable accumulation of auto-named temp directories
    /// left behind when the user launches the app, immediately
    /// loads another project (or quits without doing
    /// anything), and never returns to the temp.
    ///
    /// "Unmodified" requires *all* of:
    ///
    /// - `kind` is `Temp`.
    /// - `project.yaml` parses successfully and lists no
    ///   tables and no relationships.
    /// - `data/` is empty (so we don't delete CSVs the user
    ///   might still want even if the YAML schema list is
    ///   empty for any reason).
    ///
    /// Anything that fails defaults to "not unmodified"
    /// (false), so a corrupted project is never auto-deleted.
    #[must_use]
    pub fn is_unmodified_temp(&self) -> bool {
        if !matches!(self.kind, ProjectKind::Temp) {
            return false;
        }
        let yaml_path = self.path.join(PROJECT_YAML);
        let Ok(body) = fs::read_to_string(&yaml_path) else {
            return false;
        };
        let Ok(snapshot) = crate::persistence::parse_schema(&body) else {
            return false;
        };
        if !snapshot.tables.is_empty() || !snapshot.relationships.is_empty() {
            return false;
        }
        // Defensive belt-and-braces: data/ must also be empty.
        // This catches edge cases like a hand-edited YAML that
        // lists no tables but the data dir still contains a
        // user's CSV.
        let data_dir = self.path.join(DATA_DIR);
        // Missing data dir → also "no data" → empty.
        fs::read_dir(&data_dir).map_or(true, |mut iter| iter.next().is_none())
    }

    /// Path to the SQLite database for this project. Always
    /// `<project>/playground.db`.
    #[must_use]
    pub fn db_path(&self) -> PathBuf {
        self.path.join(PLAYGROUND_DB)
    }
}

/// Whitelisted file/directory names allowed inside a temp
/// project we're about to auto-delete. Anything else makes
/// `safely_delete_temp_project` refuse.
const ALLOWED_PROJECT_ENTRIES: &[&str] = &[
    PROJECT_YAML,
    DATA_DIR,
    HISTORY_LOG,
    PLAYGROUND_DB,
    GITIGNORE,
    ".rdbms-playground.lock",
    // Undo snapshot ring (ADR-0006 Amendment 1): a temp that was
    // modified then undone back to empty can still carry this, and
    // must remain auto-deletable.
    crate::undo::SNAPSHOTS_DIR,
];

/// Reasons `safely_delete_temp_project` refuses to delete a
/// path. Each variant carries enough detail to surface in a
/// log warning.
#[derive(Debug)]
pub enum SafeDeleteError {
    Refused {
        path: PathBuf,
        reason: &'static str,
    },
    Io {
        path: PathBuf,
        source: std::io::Error,
    },
}

impl std::fmt::Display for SafeDeleteError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Refused { path, reason } => f.write_str(&crate::t!(
                "project.safe_delete.refused",
                path = path.display(),
                reason = reason,
            )),
            Self::Io { path, source } => f.write_str(&crate::t!(
                "project.safe_delete.io",
                path = path.display(),
                source = source,
            )),
        }
    }
}

impl std::error::Error for SafeDeleteError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io { source, .. } => Some(source),
            Self::Refused { .. } => None,
        }
    }
}

/// Conservatively delete a temp project's directory.
///
/// Stacks every guard we can think of so a bug elsewhere (or
/// a maliciously crafted CLI argument) can never escalate
/// into deleting the wrong directory:
///
/// 1. **Canonicalize** the path — resolves symlinks and `..`,
///    normalises against the actual filesystem.
/// 2. **Symlink rejection** at the top level — if the path
///    *is* a symlink, refuse.
/// 3. **Must be a directory** — `is_dir()` after the symlink
///    check.
/// 4. **Containment** — the canonical path must start with
///    the canonical `<data_root>/projects/`. Anything outside
///    that prefix (a user's home, /tmp, the system root)
///    cannot pass.
/// 5. **Marker-segment match** — the directory's basename
///    must contain the literal `[temp]` segment, the same
///    marker `validate_user_name` reserves so user-named
///    projects can never collide.
/// 6. **Allowlisted contents** — every direct child must be
///    one of the well-known project artefacts (or a migration
///    backup or atomic-write `.tmp` file). Refuse if anything
///    foreign is present (a user's `notes.md`, a stray
///    `.git/`, etc.).
///
/// Any single guard failing produces a `SafeDeleteError` and
/// no `remove_dir_all` runs. The caller is expected to log
/// the refusal and move on — leaving an unexpected directory
/// alone is always preferable to a wrong delete.
pub fn safely_delete_temp_project(
    project_path: &Path,
    data_root: &Path,
) -> Result<(), SafeDeleteError> {
    // 1. Reject symlinks at the top level (before we
    //    canonicalize). canonicalize() would silently follow
    //    the link and pass the rest of the checks against
    //    the *target*, which is not what we want.
    let top_meta = fs::symlink_metadata(project_path).map_err(|source| SafeDeleteError::Io {
        path: project_path.to_path_buf(),
        source,
    })?;
    if top_meta.file_type().is_symlink() {
        return Err(SafeDeleteError::Refused {
            path: project_path.to_path_buf(),
            reason: "path is a symbolic link",
        });
    }
    if !top_meta.is_dir() {
        return Err(SafeDeleteError::Refused {
            path: project_path.to_path_buf(),
            reason: "path is not a directory",
        });
    }

    // 2. Canonicalize for the containment check. We do this
    //    only after the symlink-at-top check so we can't be
    //    tricked by a top-level symlink.
    let project_canon = fs::canonicalize(project_path).map_err(|source| SafeDeleteError::Io {
        path: project_path.to_path_buf(),
        source,
    })?;

    // 3. Containment: canonical path must be inside the
    //    canonical data-root projects dir.
    let projects_root = projects_dir(data_root);
    let projects_root_canon = fs::canonicalize(&projects_root).unwrap_or(projects_root);
    if !project_canon.starts_with(&projects_root_canon) {
        return Err(SafeDeleteError::Refused {
            path: project_canon,
            reason: "path is not inside the active data dir's projects folder",
        });
    }

    // 4. Marker segment: basename must contain `[temp]` per
    //    naming::is_temp_dirname.
    let dirname = match project_canon.file_name().and_then(|n| n.to_str()) {
        Some(s) => s,
        None => {
            return Err(SafeDeleteError::Refused {
                path: project_canon,
                reason: "directory has no usable basename",
            });
        }
    };
    if !naming::is_temp_dirname(dirname) {
        return Err(SafeDeleteError::Refused {
            path: project_canon,
            reason: "directory name does not contain the [temp] marker",
        });
    }

    // 5. Contents allowlist. Any direct child not in
    //    ALLOWED_PROJECT_ENTRIES (and not a migration backup
    //    or staged-write tmp) makes us refuse.
    let entries = fs::read_dir(&project_canon).map_err(|source| SafeDeleteError::Io {
        path: project_canon.clone(),
        source,
    })?;
    for entry in entries {
        let entry = entry.map_err(|source| SafeDeleteError::Io {
            path: project_canon.clone(),
            source,
        })?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy().into_owned();
        let is_allowed = ALLOWED_PROJECT_ENTRIES.iter().any(|a| *a == name_str)
            || name_str.starts_with("project.yaml.v") && name_str.ends_with(".bak")
            || name_str.ends_with(".tmp");
        if !is_allowed {
            return Err(SafeDeleteError::Refused {
                path: project_canon.clone(),
                reason: "directory contains an unexpected file",
            });
        }
    }

    // All guards passed. Remove the directory.
    fs::remove_dir_all(&project_canon).map_err(|source| SafeDeleteError::Io {
        path: project_canon,
        source,
    })?;
    Ok(())
}

/// Copy a project directory to a new location.
///
/// Used by `save` / `save as` (ADR-0015 §11). Excludes the
/// per-process lock file (a fresh one is acquired when the
/// destination project is opened); copies everything else
/// including `playground.db`. The target path must not
/// already exist (per the §2 collision rule); the caller is
/// expected to validate that before invoking this helper.
pub fn copy_project(src: &Path, dst: &Path) -> Result<(), ProjectError> {
    if dst.exists() {
        return Err(ProjectError::AlreadyExists(dst.to_path_buf()));
    }
    copy_dir_recursive(src, dst).map_err(|source| ProjectError::Io {
        path: dst.to_path_buf(),
        source,
    })?;
    Ok(())
}

fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
    fs::create_dir_all(dst)?;
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let name = entry.file_name();
        if name == ".rdbms-playground.lock" {
            // Per-process artifact; the destination project
            // will write a fresh one when opened.
            continue;
        }
        let src_path = entry.path();
        let dst_path = dst.join(&name);
        let file_type = entry.file_type()?;
        if file_type.is_dir() {
            copy_dir_recursive(&src_path, &dst_path)?;
        } else {
            fs::copy(&src_path, &dst_path)?;
        }
    }
    Ok(())
}

/// Heuristic for "does this directory look like an
/// rdbms-playground project?" — used by `Project::open` to
/// reject obviously-wrong CLI arguments before we try to
/// acquire a lock or touch SQLite.
fn looks_like_project(path: &Path) -> bool {
    path.join(PROJECT_YAML).exists() || path.join(PLAYGROUND_DB).exists()
}

fn ensure_dir(path: &Path) -> Result<(), ProjectError> {
    fs::create_dir_all(path).map_err(|e| ProjectError::Io {
        path: path.to_path_buf(),
        source: e,
    })
}

fn write_if_missing(path: &Path, body: &str) -> Result<(), ProjectError> {
    if path.exists() {
        debug!(path = %path.display(), "skeleton file already present, leaving as-is");
        return Ok(());
    }
    fs::write(path, body).map_err(|e| ProjectError::Io {
        path: path.to_path_buf(),
        source: e,
    })
}

fn directory_name(path: &Path) -> String {
    path.file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string())
}

/// Current UTC time as an ISO-8601 string with second
/// precision and a `Z` suffix. Mirrors the `history.log`
/// timestamp format (ADR-0015 §5).
fn iso8601_now() -> String {
    let now = std::time::SystemTime::now();
    let secs = now
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    iso8601_from_unix_secs(secs)
}

fn iso8601_from_unix_secs(secs: i64) -> String {
    let day_secs = secs.rem_euclid(86_400);
    let h = day_secs / 3600;
    let m = (day_secs % 3600) / 60;
    let s = day_secs % 60;
    let (y, mo, d) = naming_ymd(secs);
    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}

/// Wrapper that delegates to the same conversion the naming
/// module uses, kept private so we don't expose the helper
/// twice.
const fn naming_ymd(secs: i64) -> (u32, u32, u32) {
    // Re-implement the same Howard-Hinnant conversion locally
    // so we don't reach into another module's private fn.
    let days = secs.div_euclid(86_400);
    let z = days + 719_468;
    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
    let doe = (z - era * 146_097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as u32, m as u32, d as u32)
}

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

    fn tempdir() -> tempfile::TempDir {
        tempfile::tempdir().expect("create tempdir")
    }

    #[test]
    fn data_root_override_is_used_verbatim() {
        let tmp = tempdir();
        let resolved = resolve_data_root(Some(tmp.path())).unwrap();
        assert_eq!(resolved, tmp.path());
    }

    #[test]
    fn data_root_default_returns_some_path() {
        // Can't assert the exact path (it depends on the host
        // OS and env), but we can confirm we get *something*
        // sensible-looking.
        let resolved = resolve_data_root(None).unwrap();
        let s = resolved.display().to_string();
        assert!(
            s.contains("rdbms-playground"),
            "expected resolved path to mention the app name; got: {s}"
        );
    }

    #[test]
    fn create_temp_builds_skeleton() {
        let tmp = tempdir();
        let project = Project::create_temp(tmp.path()).expect("create temp");

        let path = project.path();
        assert!(path.exists());
        assert!(path.join(PROJECT_YAML).exists());
        assert!(path.join(DATA_DIR).is_dir());
        assert!(path.join(HISTORY_LOG).exists());
        assert!(path.join(GITIGNORE).exists());

        // playground.db is created lazily by SQLite, not by us.
        assert!(!path.join(PLAYGROUND_DB).exists());

        // Lock file must exist while we hold the project.
        assert!(path.join(".rdbms-playground.lock").exists());

        // YAML carries version + created_at.
        let yaml = fs::read_to_string(path.join(PROJECT_YAML)).unwrap();
        assert!(yaml.contains("version: 2"));
        assert!(yaml.contains("created_at:"));

        // .gitignore matches ADR-0015.
        let gi = fs::read_to_string(path.join(GITIGNORE)).unwrap();
        assert!(gi.contains("/playground.db"));
        assert!(gi.contains("/.rdbms-playground.lock"));
        assert!(gi.contains("/.snapshots/"), "undo ring should be ignored");
        assert!(
            !gi.contains("history.log"),
            "history.log should NOT be ignored"
        );
    }

    #[test]
    fn temp_project_lives_under_projects_subdir() {
        let tmp = tempdir();
        let project = Project::create_temp(tmp.path()).expect("create temp");
        let parent = project.path().parent().unwrap();
        assert_eq!(parent.file_name().unwrap(), PROJECTS_SUBDIR);
    }

    #[test]
    fn create_temp_display_name_is_prettified() {
        let tmp = tempdir();
        let project = Project::create_temp(tmp.path()).expect("create temp");
        // Name should not start with a digit (date prefix
        // stripped) and each word capitalized.
        let dn = project.display_name();
        assert!(
            dn.chars().next().map(char::is_uppercase).unwrap_or(false),
            "expected title-cased display name, got: {dn}"
        );
        assert!(!dn.contains('-'));
        assert!(!dn.starts_with(char::is_numeric));
    }

    #[test]
    fn drop_releases_lock() {
        let tmp = tempdir();
        let path = {
            let project = Project::create_temp(tmp.path()).expect("create temp");
            project.path().to_path_buf()
        };
        assert!(!path.join(".rdbms-playground.lock").exists());
    }

    #[test]
    fn create_named_refuses_existing_path() {
        let tmp = tempdir();
        let target = tmp.path().join("MyProject");
        fs::create_dir(&target).unwrap();
        let err = Project::create_named(&target).expect_err("must refuse");
        assert!(
            matches!(err, ProjectError::AlreadyExists(_)),
            "got: {err:?}"
        );
    }

    #[test]
    fn create_named_builds_skeleton_at_arbitrary_path() {
        let tmp = tempdir();
        let target = tmp.path().join("TermPlanner");
        let project = Project::create_named(&target).expect("create named");
        assert_eq!(project.display_name(), "Term Planner");
        assert!(target.join(PROJECT_YAML).exists());
    }

    #[test]
    fn open_refuses_nonexistent_path() {
        let tmp = tempdir();
        let err = Project::open(&tmp.path().join("does-not-exist")).expect_err("must refuse");
        assert!(matches!(err, ProjectError::PathNotFound(_)), "got: {err:?}");
    }

    #[test]
    fn open_refuses_non_project_directory() {
        let tmp = tempdir();
        let dir = tmp.path().join("random");
        fs::create_dir(&dir).unwrap();
        fs::write(dir.join("README.txt"), "hello").unwrap();
        let err = Project::open(&dir).expect_err("must refuse");
        assert!(matches!(err, ProjectError::NotAProject(_)), "got: {err:?}");
    }

    #[test]
    fn open_succeeds_after_create() {
        let tmp = tempdir();
        let path = {
            let project = Project::create_temp(tmp.path()).expect("create");
            project.path().to_path_buf()
        };
        // Re-open after the original Project was dropped.
        let reopened = Project::open(&path).expect("reopen");
        assert_eq!(reopened.path(), path);
    }

    #[test]
    fn db_path_points_inside_project() {
        let tmp = tempdir();
        let project = Project::create_temp(tmp.path()).expect("create");
        assert_eq!(project.db_path(), project.path().join(PLAYGROUND_DB));
    }

    #[test]
    fn read_last_project_returns_none_when_missing() {
        let tmp = tempdir();
        assert!(read_last_project(tmp.path()).unwrap().is_none());
    }

    #[test]
    fn write_then_read_last_project_round_trips() {
        let tmp = tempdir();
        let target = std::path::PathBuf::from("/tmp/some/project");
        write_last_project(tmp.path(), &target).unwrap();
        let read_back = read_last_project(tmp.path()).unwrap();
        assert_eq!(read_back, Some(target));
    }

    #[test]
    fn last_project_strips_trailing_whitespace() {
        let tmp = tempdir();
        fs::write(
            tmp.path().join(LAST_PROJECT_FILE),
            "/tmp/some/project\n\n  ",
        )
        .unwrap();
        let read_back = read_last_project(tmp.path()).unwrap();
        assert_eq!(
            read_back,
            Some(std::path::PathBuf::from("/tmp/some/project"))
        );
    }

    #[test]
    fn empty_last_project_file_is_treated_as_none() {
        let tmp = tempdir();
        fs::write(tmp.path().join(LAST_PROJECT_FILE), "   \n").unwrap();
        assert!(read_last_project(tmp.path()).unwrap().is_none());
    }
}
