//! Project aggregate root and its path value object.

use serde::{Deserialize, Serialize};

use crate::error::DomainError;
use crate::ids::ProjectId;
use crate::remote::RemoteRef;

/// A normalized, absolute project path, aware of its target platform.
///
/// Invariant: the path is **absolute** for its platform. We accept POSIX
/// absolute paths (`/...`), Windows absolute paths (`C:\...` or `\\server\...`)
/// and WSL mount paths (`/mnt/c/...`, themselves POSIX-absolute).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ProjectPath(String);

impl ProjectPath {
    /// Builds a validated project path.
    ///
    /// # Errors
    /// Returns [`DomainError::PathNotAbsolute`] if `raw` is not absolute, or
    /// [`DomainError::EmptyField`] if it is empty.
    pub fn new(raw: impl Into<String>) -> Result<Self, DomainError> {
        let raw = raw.into();
        crate::validation::non_empty(&raw, "project.root")?;
        if Self::is_absolute(&raw) {
            Ok(Self(raw))
        } else {
            Err(DomainError::PathNotAbsolute { path: raw })
        }
    }

    /// Returns whether `raw` is an absolute path on any supported platform.
    fn is_absolute(raw: &str) -> bool {
        let bytes = raw.as_bytes();
        // POSIX / WSL absolute.
        if bytes.first() == Some(&b'/') {
            return true;
        }
        // Windows UNC.
        if raw.starts_with("\\\\") {
            return true;
        }
        // Windows drive-letter absolute: `C:\` or `C:/`.
        if bytes.len() >= 3
            && bytes[0].is_ascii_alphabetic()
            && bytes[1] == b':'
            && (bytes[2] == b'\\' || bytes[2] == b'/')
        {
            return true;
        }
        false
    }

    /// Returns the raw path string.
    #[must_use]
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for ProjectPath {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

/// Project aggregate root.
///
/// Invariants enforced here:
/// - `name` non-empty,
/// - `root` is an absolute [`ProjectPath`].
///
/// The cross-aggregate uniqueness invariant — "no two projects share the same
/// `(remote, root)`" — is a *repository* concern (it requires knowledge of all
/// projects) and is enforced by the `ProjectStore`/use case layer, not here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Project {
    /// Stable identifier.
    pub id: ProjectId,
    /// Display name.
    pub name: String,
    /// Absolute project root.
    pub root: ProjectPath,
    /// Where the project physically lives.
    pub remote: RemoteRef,
    /// Creation timestamp, as epoch milliseconds (supplied via a `Clock` port).
    pub created_at: i64,
}

impl Project {
    /// Builds a validated project.
    ///
    /// # Errors
    /// Returns [`DomainError::EmptyField`] if `name` is empty.
    pub fn new(
        id: ProjectId,
        name: impl Into<String>,
        root: ProjectPath,
        remote: RemoteRef,
        created_at: i64,
    ) -> Result<Self, DomainError> {
        let name = name.into();
        crate::validation::non_empty(&name, "project.name")?;
        Ok(Self {
            id,
            name,
            root,
            remote,
            created_at,
        })
    }
}
