//! Component and WIT packaging primitives shared by the Wasmeld CLI, Console,
//! and Runtime.
//!
//! A runnable component is distributed as a constrained `.wasmpkg` ZIP
//! containing exactly [`PACKAGE_MANIFEST_PATH`] and [`COMPONENT_PATH`]. WIT
//! packages use the Component Model binary WIT encoding implemented by the
//! [`wit_package`] module; they are not stored in `.wasmpkg` containers.
//!
//! # Example
//!
//! ```
//! use std::io::Cursor;
//! use wasmeld_package::{read_package, write_package};
//!
//! let component = b"\0asm\x0d\0\x01\0";
//! let mut archive = Cursor::new(Vec::new());
//! write_package(
//!     &mut archive,
//!     "image-resize",
//!     "1.0.0",
//!     "example:image-resize/service@1.0.0",
//!     component,
//! )?;
//!
//! let decoded = read_package(archive.get_ref(), 1024 * 1024)?;
//! assert_eq!(decoded.manifest.id, "image-resize");
//! assert_eq!(decoded.component, component);
//! # Ok::<(), wasmeld_package::PackageError>(())
//! ```
//!
//! The decoder treats an uploaded archive as untrusted input: it accepts only
//! the two canonical entries, limits uncompressed sizes, validates identifiers,
//! and verifies the Component digest before returning bytes to the Runtime.

#![warn(missing_docs)]

use std::{
    collections::BTreeSet,
    io::{self, Cursor, Read, Seek, Write},
};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
use zip::{CompressionMethod, ZipArchive, ZipWriter, result::ZipError, write::SimpleFileOptions};

/// Resolves `wasmeld.toml`, `wit.lock`, Registry dependencies, and path replacements.
pub mod module;
/// Builds and inspects standard binary WIT packages.
pub mod wit_package;

/// Current `.wasmpkg` manifest schema.
pub const PACKAGE_SCHEMA_VERSION: u32 = 1;
/// Fixed manifest entry name inside a `.wasmpkg` archive.
pub const PACKAGE_MANIFEST_PATH: &str = "package.toml";
/// Fixed component entry name inside a `.wasmpkg` archive.
pub const COMPONENT_PATH: &str = "component.wasm";
/// Baseline service world implemented by Wasmeld components.
pub const SERVICE_WORLD: &str = "wasmeld:service/service-component@0.1.0";
const MAX_MANIFEST_BYTES: usize = 64 * 1024;

/// Identity and integrity metadata embedded in a `.wasmpkg` archive.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PackageManifest {
    /// Manifest schema understood by this version of Wasmeld.
    pub schema_version: u32,
    /// Stable service identifier used in management and gateway routes.
    pub id: String,
    /// Immutable service revision; the same `id@revision` cannot be overwritten.
    pub revision: String,
    /// Fully versioned WIT world implemented by the Component.
    pub world: String,
    /// Archive path of the executable Component; currently always [`COMPONENT_PATH`].
    pub component: String,
    /// Lowercase SHA-256 digest of the uncompressed Component bytes.
    pub sha256: String,
}

impl PackageManifest {
    /// Creates a manifest and computes the component digest.
    ///
    /// This constructor does not reject invalid identifiers or worlds. Call
    /// [`Self::validate`] before persisting it; [`write_package`] does this
    /// automatically.
    pub fn new(
        id: impl Into<String>,
        revision: impl Into<String>,
        world: impl Into<String>,
        component: &[u8],
    ) -> Self {
        Self {
            schema_version: PACKAGE_SCHEMA_VERSION,
            id: id.into(),
            revision: revision.into(),
            world: world.into(),
            component: COMPONENT_PATH.to_owned(),
            sha256: component_sha256(component),
        }
    }

    /// Validates schema compatibility, identifiers, world syntax, and digest format.
    pub fn validate(&self) -> Result<(), PackageError> {
        if self.schema_version != PACKAGE_SCHEMA_VERSION {
            return Err(PackageError::InvalidPackage(format!(
                "unsupported package schema version {}, expected {PACKAGE_SCHEMA_VERSION}",
                self.schema_version
            )));
        }
        validate_identifier("id", &self.id)?;
        validate_identifier("revision", &self.revision)?;
        validate_world(&self.world)?;
        if self.component != COMPONENT_PATH {
            return Err(PackageError::InvalidPackage(format!(
                "component must be {COMPONENT_PATH:?}"
            )));
        }
        if self.sha256.len() != 64
            || !self
                .sha256
                .bytes()
                .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase())
        {
            return Err(PackageError::InvalidPackage(
                "sha256 must be a lowercase SHA-256 digest".to_owned(),
            ));
        }
        Ok(())
    }
}

/// A validated component package decoded from a `.wasmpkg` archive.
#[derive(Debug)]
pub struct ComponentPackage {
    /// Validated identity, world, and integrity metadata.
    pub manifest: PackageManifest,
    /// Uncompressed Component bytes whose digest matches the manifest.
    pub component: Vec<u8>,
}

/// Errors produced while creating or validating component packages.
#[derive(Debug, Error)]
pub enum PackageError {
    /// The archive layout or a manifest value violates the package contract.
    #[error("invalid component package: {0}")]
    InvalidPackage(String),

    /// The uncompressed Component exceeds the caller-selected upload limit.
    #[error("component exceeds the {limit}-byte uncompressed limit")]
    ComponentTooLarge {
        /// Maximum number of accepted uncompressed bytes.
        limit: usize,
    },

    /// Component bytes do not match the immutable digest in `package.toml`.
    #[error("component digest mismatch: expected {expected}, found {actual}")]
    DigestMismatch {
        /// Digest declared by the package manifest.
        expected: String,
        /// Digest computed from the uploaded Component.
        actual: String,
    },

    /// Reading or writing the package stream failed.
    #[error("failed to read or write package: {0}")]
    Io(#[from] io::Error),

    /// The bytes are not a valid constrained ZIP archive.
    #[error("invalid ZIP container: {0}")]
    Zip(#[from] ZipError),

    /// `package.toml` could not be decoded.
    #[error("invalid package manifest: {0}")]
    ManifestParse(#[from] toml::de::Error),

    /// A generated package manifest could not be encoded.
    #[error("failed to serialize package manifest: {0}")]
    ManifestSerialize(#[from] toml::ser::Error),
}

/// Writes a deterministic two-entry `.wasmpkg` archive.
///
/// The caller owns the destination writer. Runtime limits are intentionally not
/// included because the Console injects platform policy during registration.
///
/// # Errors
///
/// Returns an error when the identity or world is invalid, the Component is
/// empty, or the destination cannot be written.
pub fn write_package<W>(
    writer: W,
    id: impl Into<String>,
    revision: impl Into<String>,
    world: impl Into<String>,
    component: &[u8],
) -> Result<PackageManifest, PackageError>
where
    W: Write + Seek,
{
    if component.is_empty() {
        return Err(PackageError::InvalidPackage(
            "component must not be empty".to_owned(),
        ));
    }

    let manifest = PackageManifest::new(id, revision, world, component);
    manifest.validate()?;
    let manifest_toml = toml::to_string_pretty(&manifest)?;
    let options = SimpleFileOptions::default()
        .compression_method(CompressionMethod::Deflated)
        .unix_permissions(0o644);
    let mut archive = ZipWriter::new(writer);
    archive.start_file(PACKAGE_MANIFEST_PATH, options)?;
    archive.write_all(manifest_toml.as_bytes())?;
    archive.start_file(COMPONENT_PATH, options)?;
    archive.write_all(component)?;
    archive.finish()?;
    Ok(manifest)
}

/// Reads and validates a `.wasmpkg` archive.
///
/// `max_component_bytes` applies to the uncompressed component so compressed
/// archives cannot bypass the platform's artifact limit.
///
/// # Errors
///
/// Returns an error for malformed ZIP data, extra or duplicate entries,
/// oversized content, invalid metadata, empty Components, or digest mismatch.
pub fn read_package(
    package_bytes: &[u8],
    max_component_bytes: usize,
) -> Result<ComponentPackage, PackageError> {
    let mut archive = ZipArchive::new(Cursor::new(package_bytes))?;
    if archive.len() != 2 {
        return Err(PackageError::InvalidPackage(format!(
            "archive must contain exactly {PACKAGE_MANIFEST_PATH} and {COMPONENT_PATH}"
        )));
    }

    let mut names = BTreeSet::new();
    for index in 0..archive.len() {
        let entry = archive.by_index(index)?;
        let name = entry.name().to_owned();
        if name != PACKAGE_MANIFEST_PATH && name != COMPONENT_PATH {
            return Err(PackageError::InvalidPackage(format!(
                "unexpected archive entry {name:?}"
            )));
        }
        if !names.insert(name.clone()) {
            return Err(PackageError::InvalidPackage(format!(
                "duplicate archive entry {name:?}"
            )));
        }
    }
    if !names.contains(PACKAGE_MANIFEST_PATH) || !names.contains(COMPONENT_PATH) {
        return Err(PackageError::InvalidPackage(format!(
            "archive must contain {PACKAGE_MANIFEST_PATH} and {COMPONENT_PATH}"
        )));
    }

    let manifest_bytes = read_entry(&mut archive, PACKAGE_MANIFEST_PATH, MAX_MANIFEST_BYTES)
        .map_err(|error| match error {
            PackageError::ComponentTooLarge { .. } => PackageError::InvalidPackage(format!(
                "{PACKAGE_MANIFEST_PATH} exceeds the {MAX_MANIFEST_BYTES}-byte limit"
            )),
            other => other,
        })?;
    let manifest = toml::from_slice::<PackageManifest>(&manifest_bytes)?;
    manifest.validate()?;

    let component = read_entry(&mut archive, COMPONENT_PATH, max_component_bytes)?;
    if component.is_empty() {
        return Err(PackageError::InvalidPackage(
            "component must not be empty".to_owned(),
        ));
    }
    let actual = component_sha256(&component);
    if actual != manifest.sha256 {
        return Err(PackageError::DigestMismatch {
            expected: manifest.sha256,
            actual,
        });
    }

    Ok(ComponentPackage {
        manifest,
        component,
    })
}

fn read_entry<R: Read + Seek>(
    archive: &mut ZipArchive<R>,
    name: &str,
    limit: usize,
) -> Result<Vec<u8>, PackageError> {
    let entry = archive.by_name(name)?;
    if entry.size() > limit as u64 {
        return Err(PackageError::ComponentTooLarge { limit });
    }
    let mut bytes = Vec::with_capacity(entry.size() as usize);
    entry
        .take(limit.saturating_add(1) as u64)
        .read_to_end(&mut bytes)?;
    if bytes.len() > limit {
        return Err(PackageError::ComponentTooLarge { limit });
    }
    Ok(bytes)
}

fn component_sha256(component: &[u8]) -> String {
    format!("{:x}", Sha256::digest(component))
}

fn validate_identifier(name: &str, value: &str) -> Result<(), PackageError> {
    let valid = !value.is_empty()
        && value.len() <= 128
        && value
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'));
    if valid {
        Ok(())
    } else {
        Err(PackageError::InvalidPackage(format!(
            "{name} must contain only ASCII letters, digits, '.', '_' or '-' and be at most 128 bytes"
        )))
    }
}

/// Validates the canonical `namespace:package/world@version` world form.
pub fn validate_world(world: &str) -> Result<(), PackageError> {
    let valid = !world.is_empty()
        && world.len() <= 256
        && world.contains(':')
        && world.contains('/')
        && world.contains('@')
        && world.bytes().all(|byte| {
            byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'/' | b'@' | b'-' | b'_' | b'.')
        });
    if valid {
        Ok(())
    } else {
        Err(PackageError::InvalidPackage(
            "world must be a versioned WIT world path and be at most 256 bytes".to_owned(),
        ))
    }
}

#[cfg(test)]
mod tests {
    use std::io::{Cursor, Write};

    use zip::{ZipWriter, write::SimpleFileOptions};

    use super::*;

    #[test]
    fn package_round_trip() {
        let mut bytes = Cursor::new(Vec::new());
        let written =
            write_package(&mut bytes, "echo", "0.1.0", SERVICE_WORLD, b"component").unwrap();
        let package = read_package(bytes.get_ref(), 1024).unwrap();

        assert_eq!(package.manifest, written);
        assert_eq!(package.component, b"component");
    }

    #[test]
    fn rejects_unexpected_archive_entries() {
        let mut bytes = Cursor::new(Vec::new());
        let mut archive = ZipWriter::new(&mut bytes);
        let options = SimpleFileOptions::default();
        archive.start_file(PACKAGE_MANIFEST_PATH, options).unwrap();
        archive.write_all(b"invalid").unwrap();
        archive.start_file(COMPONENT_PATH, options).unwrap();
        archive.write_all(b"component").unwrap();
        archive.start_file("../escape", options).unwrap();
        archive.write_all(b"escape").unwrap();
        archive.finish().unwrap();

        let error = read_package(bytes.get_ref(), 1024).unwrap_err();
        assert!(error.to_string().contains("exactly"));
    }

    #[test]
    fn enforces_the_uncompressed_component_limit() {
        let mut bytes = Cursor::new(Vec::new());
        write_package(&mut bytes, "echo", "0.1.0", SERVICE_WORLD, b"component").unwrap();

        let error = read_package(bytes.get_ref(), 4).unwrap_err();
        assert!(matches!(
            error,
            PackageError::ComponentTooLarge { limit: 4 }
        ));
    }

    #[test]
    fn rejects_unsafe_service_identifiers() {
        let mut bytes = Cursor::new(Vec::new());
        let error = write_package(
            &mut bytes,
            "../escape",
            "0.1.0",
            SERVICE_WORLD,
            b"component",
        )
        .unwrap_err();
        assert!(error.to_string().contains("ASCII letters"));
    }

    #[test]
    fn accepts_component_specific_worlds() {
        let manifest = PackageManifest::new(
            "clock-probe",
            "0.1.0",
            "component:clock-probe/clock-probe-component@0.1.0",
            b"component",
        );
        manifest.validate().unwrap();
    }
}
