//! PM-43 publish-draft data model.
//!
//! A draft is a server-side, non-public, in-progress publish composition. It is
//! persisted as a [`DraftThingInput`]: a mirror of the generated
//! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput)
//! with every field optional, so a partial draft (saved before a name, license,
//! or any model exists) round-trips losslessly. The same camelCase field names
//! keep the payload shape aligned with the publishable record.
//!
//! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused
//! from the generated API at their default owned-string type, so an uploaded
//! image/file ref stored in a draft is byte-identical to what `publishThing`
//! consumes.
//!
//! [`DraftThingInput::assemble`] is the single conversion point from a draft to
//! a publishable `ThingInput`: it validates required structure (which is
//! enforced *structurally* by the typed record — `name`/`license`/`models`,
//! `name`/`parts`, `name`+`file` are not optional there) and then defers the
//! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft
//! validation cannot drift from the lexicon.

use jacquard_common::DefaultStr;
use jacquard_lexicon::schema::LexiconSchema;
use polymodel_api::com_atproto::repo::strong_ref::StrongRef;
use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput};
use polymodel_api::space_polymodel::library::{Bbox, File, Image};
use serde::{Deserialize, Serialize};

/// The owned string type the generated records use (`DefaultStr`).
type S = DefaultStr;

// ----------------------------------------------------------------------------
// Draft endpoint wire types
//
// Defined here (cross-target) rather than in the server-only `appview::drafts`
// module so the wasm client can share them with the server.
// ----------------------------------------------------------------------------

/// Response for create/upsert: the opaque draft id the client should keep.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DraftIdResponse {
    pub draft_id: String,
}

/// A draft listing row, rendered from denormalized columns (no payload parse).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftSummary {
    pub draft_id: String,
    pub name: Option<String>,
    pub model_count: i64,
    pub created_at: i64,
    pub updated_at: i64,
}

/// Response for delete: whether a row actually existed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeleteDraftResponse {
    pub deleted: bool,
}

/// Response for image upload: a fully-formed `#image` ready to attach.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ImageUploadResponse {
    pub image: Image,
}

/// A `ThingInput`-shaped draft payload with every field optional.
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DraftThingInput {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instructions: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cover: Option<Vec<Image>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previews: Option<Vec<Image>>,
    /// Out of scope for the PM-43 UI, carried as an optional pass-through.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub derived_from: Option<StrongRef>,
    /// Out of scope for the PM-43 UI, carried as an optional pass-through.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub intended_to_work_with: Option<Vec<StrongRef>>,
    #[serde(default)]
    pub models: Vec<DraftModelInput>,
}

/// A model within a draft. `parts` are ordered; an empty draft model is allowed
/// (it simply fails publish validation until it has a name and at least one part).
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DraftModelInput {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub summary: Option<String>,
    /// Per-model license override. Out of scope for the PM-43 UI.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub license: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tags: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instructions: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cover: Option<Vec<Image>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previews: Option<Vec<Image>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub derived_from: Option<StrongRef>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub intended_to_work_with: Option<Vec<StrongRef>>,
    #[serde(default)]
    pub parts: Vec<DraftPartInput>,
}

/// A part within a draft model. A part is publishable once it has a name and a
/// successfully-staged file (an inline `file` manifest or a stage `upload_id`).
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct DraftPartInput {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// stageFile upload id; the server resolves it to a `File` manifest at publish.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub upload_id: Option<String>,
    /// Inline file manifest (alternative to `upload_id`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub file: Option<File>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub notes: Option<String>,
    /// Out of scope for the PM-43 UI, carried as an optional pass-through.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub units: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub print_settings: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dimensions: Option<Bbox>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub previews: Option<Vec<Image>>,
    /// Out of scope for the PM-43 UI, carried as an optional pass-through.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub derived_from: Option<StrongRef>,
    /// Out of scope for the PM-43 UI, carried as an optional pass-through.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub intended_to_work_with: Option<Vec<StrongRef>>,
}

/// One actionable validation problem, addressed to a draft field path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationIssue {
    pub field: String,
    pub message: String,
}

/// All validation problems found while assembling a draft into a `ThingInput`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ValidationErrors {
    pub issues: Vec<ValidationIssue>,
}

impl ValidationErrors {
    fn push(&mut self, field: impl Into<String>, message: impl Into<String>) {
        self.issues.push(ValidationIssue {
            field: field.into(),
            message: message.into(),
        });
    }

    pub fn is_empty(&self) -> bool {
        self.issues.is_empty()
    }

    /// Whether any issue targets the given field path exactly.
    pub fn has_field(&self, field: &str) -> bool {
        self.issues.iter().any(|i| i.field == field)
    }
}

impl std::fmt::Display for ValidationErrors {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let mut first = true;
        for issue in &self.issues {
            if !first {
                write!(f, "; ")?;
            }
            write!(f, "{}: {}", issue.field, issue.message)?;
            first = false;
        }
        Ok(())
    }
}

fn nonempty(value: &Option<String>) -> bool {
    value
        .as_deref()
        .map(|v| !v.trim().is_empty())
        .unwrap_or(false)
}

fn into_s(value: &str) -> S {
    S::from(value.trim())
}

fn opt_s(value: &Option<String>) -> Option<S> {
    value
        .as_deref()
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(S::from)
}

fn opt_vec_s(value: &Option<Vec<String>>) -> Option<Vec<S>> {
    value
        .as_ref()
        .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>())
}

/// Convert a freeform multi-line text field (stored raw, one entry per line)
/// into a list of non-empty steps. Whitespace-only lines are dropped here, at
/// assembly, so the live textarea can keep spaces and blank lines while typing.
fn clean_lines(value: &Option<Vec<String>>) -> Option<Vec<S>> {
    let lines: Vec<S> = value
        .as_ref()?
        .iter()
        .map(|line| line.trim_end())
        .filter(|line| !line.trim().is_empty())
        .map(S::from)
        .collect();
    if lines.is_empty() { None } else { Some(lines) }
}

impl DraftPartInput {
    /// A part is backed by a staged file when it has either an inline manifest
    /// or a non-empty upload id.
    pub fn has_file(&self) -> bool {
        self.file.is_some() || nonempty(&self.upload_id)
    }

    /// A part is publishable when it has a non-empty name and a staged file.
    pub fn is_publishable(&self) -> bool {
        nonempty(&self.name) && self.has_file()
    }
}

impl DraftModelInput {
    /// A model is publishable when it has a non-empty name and at least one
    /// publishable part.
    pub fn is_publishable(&self) -> bool {
        nonempty(&self.name)
            && !self.parts.is_empty()
            && self.parts.iter().all(|p| p.is_publishable())
    }
}

impl DraftThingInput {
    /// Display name for draft listings, if set.
    pub fn display_name(&self) -> Option<&str> {
        self.name
            .as_deref()
            .map(str::trim)
            .filter(|v| !v.is_empty())
    }

    /// Number of models (for the denormalized `model_count` listing column).
    pub fn model_count(&self) -> i64 {
        self.models.len() as i64
    }

    /// Basics is complete once the thing has a name. License has an effective
    /// default and is chosen in the details step, so it never gates Basics.
    pub fn basics_complete(&self) -> bool {
        nonempty(&self.name)
    }

    /// Step 2 (Files & parts) is complete when there is at least one model and
    /// every model is publishable.
    pub fn files_complete(&self) -> bool {
        !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable())
    }

    /// Validate the draft and convert it into a publishable `ThingInput`.
    ///
    /// Required structure is checked here (the draft is all-optional); the
    /// length/grapheme limits are delegated to the generated
    /// [`LexiconSchema::validate`] on the assembled records so this code never
    /// re-declares the lexicon's constraints.
    pub fn assemble(&self) -> Result<ThingInput<S>, ValidationErrors> {
        let mut errors = ValidationErrors::default();

        if !nonempty(&self.name) {
            errors.push("name", "A name is required to publish.");
        }
        if self.models.is_empty() {
            errors.push("models", "Add at least one model with a file.");
        }

        for (mi, model) in self.models.iter().enumerate() {
            if !nonempty(&model.name) {
                errors.push(format!("models[{mi}].name"), "Each model needs a name.");
            }
            if model.parts.is_empty() {
                errors.push(
                    format!("models[{mi}].parts"),
                    "Each model needs at least one part with a file.",
                );
            }
            for (pi, part) in model.parts.iter().enumerate() {
                if !nonempty(&part.name) {
                    errors.push(
                        format!("models[{mi}].parts[{pi}].name"),
                        "Each part needs a name.",
                    );
                }
                if !part.has_file() {
                    errors.push(
                        format!("models[{mi}].parts[{pi}].file"),
                        "Upload a file for this part.",
                    );
                }
            }
        }

        if !errors.is_empty() {
            return Err(errors);
        }

        // Presence is satisfied above; this pass only constructs typed records.
        let models = self
            .models
            .iter()
            .map(|model| {
                let parts = model
                    .parts
                    .iter()
                    .map(|part| PartInput {
                        name: into_s(part.name.as_deref().unwrap_or_default()),
                        upload_id: opt_s(&part.upload_id),
                        file: part.file.clone(),
                        format: opt_s(&part.format),
                        notes: opt_s(&part.notes),
                        units: opt_s(&part.units),
                        print_settings: opt_vec_s(&part.print_settings),
                        dimensions: part.dimensions.clone(),
                        previews: part.previews.clone(),
                        derived_from: part.derived_from.clone(),
                        intended_to_work_with: part.intended_to_work_with.clone(),
                        ..Default::default()
                    })
                    .collect();
                ModelInput {
                    cover: model.cover.clone(),
                    derived_from: model.derived_from.clone(),
                    instructions: opt_vec_s(&model.instructions),
                    intended_to_work_with: model.intended_to_work_with.clone(),
                    license: opt_s(&model.license),
                    name: into_s(model.name.as_deref().unwrap_or_default()),
                    parts,
                    previews: model.previews.clone(),
                    summary: opt_s(&model.summary),
                    tags: opt_vec_s(&model.tags),
                    extra_data: None,
                }
            })
            .collect::<Vec<_>>();

        let thing = ThingInput {
            cover: self.cover.clone(),
            derived_from: self.derived_from.clone(),
            instructions: clean_lines(&self.instructions),
            intended_to_work_with: self.intended_to_work_with.clone(),
            license: into_s(
                self.license
                    .as_deref()
                    .map(str::trim)
                    .filter(|v| !v.is_empty())
                    .unwrap_or(super::DEFAULT_LICENSE),
            ),
            models,
            name: into_s(self.name.as_deref().unwrap_or_default()),
            previews: self.previews.clone(),
            summary: opt_s(&self.summary),
            tags: opt_vec_s(&self.tags),
            extra_data: None,
        };

        // Defer length/grapheme limits to the generated lexicon validators so
        // this code never duplicates (and cannot drift from) the constraints.
        if let Err(err) = thing.validate() {
            errors.push("name", lexicon_message(&err));
        }
        for (mi, model) in thing.models.iter().enumerate() {
            if let Err(err) = model.validate() {
                errors.push(format!("models[{mi}]"), lexicon_message(&err));
            }
            for (pi, part) in model.parts.iter().enumerate() {
                if let Err(err) = part.validate() {
                    errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err));
                }
            }
        }

        if errors.is_empty() {
            Ok(thing)
        } else {
            Err(errors)
        }
    }
}

fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String {
    format!("Exceeds a lexicon limit: {err}")
}

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

    fn part_with_upload(name: &str) -> DraftPartInput {
        DraftPartInput {
            name: Some(name.to_string()),
            upload_id: Some("abcdef0123456789-42".to_string()),
            ..Default::default()
        }
    }

    fn complete_draft() -> DraftThingInput {
        DraftThingInput {
            name: Some("Desk Organizer".to_string()),
            license: Some("CC-BY-4.0".to_string()),
            summary: Some("A tidy desk caddy.".to_string()),
            models: vec![DraftModelInput {
                name: Some("Tray".to_string()),
                parts: vec![part_with_upload("tray.stl")],
                ..Default::default()
            }],
            ..Default::default()
        }
    }

    #[test]
    fn partial_draft_round_trips_through_json() {
        let draft = DraftThingInput {
            name: Some("WIP".to_string()),
            ..Default::default()
        };
        let json = serde_json::to_string(&draft).unwrap();
        let restored: DraftThingInput = serde_json::from_str(&json).unwrap();
        assert_eq!(draft, restored);
        // A bare partial draft must not serialize required-but-absent fields.
        assert!(!json.contains("license"));
        assert!(json.contains("\"name\":\"WIP\""));
    }

    #[test]
    fn empty_draft_round_trips_and_is_not_publishable() {
        let draft = DraftThingInput::default();
        let json = serde_json::to_string(&draft).unwrap();
        let restored: DraftThingInput = serde_json::from_str(&json).unwrap();
        assert_eq!(draft, restored);
        assert!(!draft.basics_complete());
        assert!(!draft.files_complete());
        assert!(draft.assemble().is_err());
    }

    #[test]
    fn complete_draft_assembles_to_valid_thing_input() {
        let draft = complete_draft();
        assert!(draft.basics_complete());
        assert!(draft.files_complete());
        let thing = draft.assemble().expect("complete draft assembles");
        assert_eq!(thing.name.as_str(), "Desk Organizer");
        assert_eq!(thing.license.as_str(), "CC-BY-4.0");
        assert_eq!(thing.models.len(), 1);
        assert_eq!(thing.models[0].name.as_str(), "Tray");
        assert_eq!(thing.models[0].parts.len(), 1);
        assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl");
        // The assembled record passes the generated lexicon validators.
        assert!(thing.validate().is_ok());
    }

    #[test]
    fn missing_thing_fields_are_reported() {
        let draft = DraftThingInput::default();
        let errors = draft.assemble().expect_err("empty draft fails");
        assert!(errors.has_field("name"));
        assert!(errors.has_field("models"));
        // License is not required: it defaults at assembly, never an error.
        assert!(!errors.has_field("license"));
    }

    #[test]
    fn unset_license_defaults_at_assembly() {
        let mut draft = complete_draft();
        draft.license = None;
        let thing = draft.assemble().expect("unset license defaults");
        assert_eq!(thing.license.as_str(), "CC-BY-4.0");
    }

    #[test]
    fn missing_model_name_and_parts_are_reported() {
        let mut draft = complete_draft();
        draft.models = vec![DraftModelInput::default()];
        let errors = draft
            .assemble()
            .expect_err("model with no name/parts fails");
        assert!(errors.has_field("models[0].name"));
        assert!(errors.has_field("models[0].parts"));
    }

    #[test]
    fn part_without_file_is_reported() {
        let mut draft = complete_draft();
        draft.models[0].parts = vec![DraftPartInput {
            name: Some("loose.stl".to_string()),
            ..Default::default()
        }];
        let errors = draft.assemble().expect_err("part with no file fails");
        assert!(errors.has_field("models[0].parts[0].file"));
        assert!(!errors.has_field("models[0].parts[0].name"));
    }

    #[test]
    fn over_long_name_trips_lexicon_validator() {
        let mut draft = complete_draft();
        // 65 graphemes exceeds the 64-grapheme name limit.
        draft.name = Some("x".repeat(65));
        let errors = draft
            .assemble()
            .expect_err("over-long name fails lexicon validate");
        assert!(errors.has_field("name"));
    }

    #[test]
    fn whitespace_only_name_is_not_complete() {
        let mut draft = complete_draft();
        draft.name = Some("   ".to_string());
        assert!(!draft.basics_complete());
        assert!(draft.assemble().is_err());
    }
}
