//! Typed image specification for
//! [`CreateSailboxRequest`](crate::sailbox::types::CreateSailboxRequest).
//!
//! These mirror the `image.v1.ImageSpec` proto and serialize to the canonical
//! proto-JSON the backend accepts: camelCase field names, enum value names, and
//! each oneof arm as a direct field. The higher-level image-building DSL
//! (reading local files, hashing contents) lives in the language wrapper; this
//! is the typed wire spec the core sends.

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// A sailbox image: a base image plus ordered build steps.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ImageSpec {
    /// Base image to build on (the proto's `source` oneof; one arm today).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub base: Option<BaseImage>,
    /// Ordered build steps applied on top of the base image.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub build_steps: Vec<ImageBuildStep>,
    /// Environment variables baked into the image.
    #[serde(skip_serializing_if = "HashMap::is_empty")]
    pub env: HashMap<String, String>,
    /// Target CPU architecture; unset lets the backend choose.
    #[serde(skip_serializing_if = "ImageArchitecture::is_unspecified")]
    pub architecture: ImageArchitecture,
    /// Exact Python version to install as `python3`; empty uses the builder default.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub python_version: String,
}

/// A supported base image.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum BaseImage {
    /// Unset; the backend rejects a create without a base.
    #[default]
    #[serde(rename = "BASE_IMAGE_UNSPECIFIED")]
    Unspecified,
    /// Debian.
    #[serde(rename = "BASE_IMAGE_DEBIAN")]
    Debian,
    /// Debian plus a baked dev layer (node LTS, build tools, editor-server OS
    /// prerequisites). Prebuilt only: supports no python_version, build steps,
    /// or env.
    #[serde(rename = "BASE_IMAGE_DEVBOX")]
    Devbox,
}

/// A target CPU architecture.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageArchitecture {
    /// Unset; the backend picks a default.
    #[default]
    #[serde(rename = "IMAGE_ARCHITECTURE_UNSPECIFIED")]
    Unspecified,
    /// x86-64.
    #[serde(rename = "IMAGE_ARCHITECTURE_AMD64")]
    Amd64,
    /// ARM64.
    #[serde(rename = "IMAGE_ARCHITECTURE_ARM64")]
    Arm64,
}

impl ImageArchitecture {
    // Takes `&self` because serde's `skip_serializing_if` requires a `fn(&T)`.
    #[allow(clippy::trivially_copy_pass_by_ref)]
    fn is_unspecified(&self) -> bool {
        matches!(self, ImageArchitecture::Unspecified)
    }
}

/// One build step: exactly one operation (the proto's `step` oneof).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ImageBuildStep {
    /// Install system packages with apt.
    AptInstall(PackageInstall),
    /// Install Python packages with pip.
    PipInstall(PackageInstall),
    /// Run a shell command.
    RunCommand(RunCommand),
    /// Add one local file, referenced by its content hash.
    AddLocalFile(AddLocalFile),
    /// Add a tree of local files.
    AddLocalDir(AddLocalDir),
}

/// A set of packages to install.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct PackageInstall {
    /// Package names.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub packages: Vec<String>,
}

/// A shell command to run during the build.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct RunCommand {
    /// The command line.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub command: String,
}

/// One local file copied into the image at `remote_path`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalFile {
    /// Lowercase hex sha256 of the file contents.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_sha256: String,
    /// Absolute path inside the rootfs.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub remote_path: String,
    /// Permission bits (low 9); `0` means the builder default (0644).
    #[serde(skip_serializing_if = "is_zero")]
    pub mode: u32,
}

/// A tree of local files copied into the image under `remote_path`.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalDir {
    /// Absolute path inside the rootfs where the files land.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub remote_path: String,
    /// The files in the tree.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub files: Vec<AddLocalDirFile>,
}

/// One file within an [`AddLocalDir`].
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct AddLocalDirFile {
    /// Path relative to the dir's `remote_path`.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub relative_path: String,
    /// Lowercase hex sha256 of the file contents.
    #[serde(skip_serializing_if = "String::is_empty")]
    pub content_sha256: String,
    /// Permission bits (low 9); `0` means the builder default (0644).
    #[serde(skip_serializing_if = "is_zero")]
    pub mode: u32,
}

// Takes `&u32` because serde's `skip_serializing_if` requires a `fn(&T)`.
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_zero(n: &u32) -> bool {
    *n == 0
}
