//! `propose_adr` — declare a new ADR in `Proposed` status.
//!
//! Loads the ondaod (Dao Discipline) context as precondition so the
//! pre-validator can confirm the proposer named which Spiral tensions
//! the decision engages (or explicitly empty with rationale). The body
//! writes a minimal NCL stub at `adrs/adr-<id>-<slug>.ncl` and appends
//! the canonical ADR entity to the commit layer.

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

use ontoref_derive::{onto_operation, onto_validator};
use ontoref_types::canonical;
use ontoref_types::operation::{OpBody, OpPayload};
use ontoref_types::{AttrId, EntityId, Operation, Value};
use smol_str::SmolStr;

use crate::context::OpContext;
use crate::error::OpError;
use crate::output::{EffectRecord, OpOutput};
use crate::validation::traits::{Slice, ValidationCtx};
use crate::validation::verdict::Verdict;

/// Witness payload kind emitted by `propose_adr`.
pub const WITNESS_SHAPE: &str = "adr_proposed";

/// Propose a new ADR. Inputs:
/// - `id` (String): canonical id `adr-NNN`.
/// - `slug` (String): kebab-case slug for the file name.
/// - `title` (String).
/// - `decision` (String): the decision text.
/// - `ondaod_evaluation` (Object): `{ engaged_tensions: [...], synthesis_state,
///   motion_direction }`.
#[onto_operation(
    id = "propose_adr",
    description = "Declare a new ADR in Proposed status. Requires ondaod evaluation as \
                   precondition (ADR-024 / dao-discipline).",
    validation_sla = "Synchronous",
    precondition = "ondaod",
    pre_validators = "ondaod_engagement_declared",
    effects = "Insert:ADR:title,status,decision",
    render_paths = "",
    witness_shape = "adr_proposed",
    actor_policy = "developer,admin"
)]
fn propose_adr(inputs: &serde_json::Value, ctx: &mut OpContext) -> Result<OpOutput, OpError> {
    let id = require_str(inputs, "id")?;
    let slug = require_str(inputs, "slug")?;
    let title = require_str(inputs, "title")?;
    let decision = require_str(inputs, "decision")?;
    validate_adr_id(&id)?;
    validate_slug(&slug)?;

    // Apply the EntityAssert to the commit layer so the witness covers
    // the ADR's identity + status cells.
    let signing_key = ctx.signing_key.clone();
    let actor = ontoref_types::PublicKey::from_signing_key(&signing_key);
    let timestamp = ctx.next_hlc();
    let entity_id = id.clone();
    let op = Operation::sign(
        OpBody {
            actor,
            parents: vec![],
            payload: OpPayload::EntityAssert {
                attrs: vec![
                    (AttrId::new("title"), Value::Str(SmolStr::new(&title))),
                    (AttrId::new("status"), Value::Str(SmolStr::new("Proposed"))),
                    (AttrId::new("decision"), Value::Str(SmolStr::new(&decision))),
                ],
                entity: EntityId::new(&entity_id),
                valid_from: None,
            },
            timestamp,
        },
        &signing_key,
    );
    ctx.commit_layer.apply(&op);

    // Write the minimal ADR file. Phase 1 stub — full ADR schema
    // satisfaction lands when the core ADR renderer is generalised.
    if let Some(root) = ctx.project_root.as_ref() {
        let adr_dir = ontoref_ontology::layout::canonical_section(root, "adrs");
        std::fs::create_dir_all(&adr_dir).map_err(|e| OpError::Render {
            path: adr_dir.display().to_string(),
            reason: format!("creating adrs/ dir: {e}"),
        })?;
        let path = adr_dir.join(format!("{id}-{slug}.ncl"));
        let body = render_adr_stub(&id, &title, &decision);
        std::fs::write(&path, body).map_err(|e| OpError::Render {
            path: path.display().to_string(),
            reason: format!("writing ADR file: {e}"),
        })?;
    }

    let witness_payload =
        canonical::encode(&op).map_err(|e| OpError::body("propose_adr", e.to_string()))?;

    Ok(OpOutput {
        effects: vec![EffectRecord {
            kind: "Insert".to_owned(),
            entity: entity_id,
            attrs: vec!["title".into(), "status".into(), "decision".into()],
            dimensions: vec![],
        }],
        witness_payload,
    })
}

fn require_str(v: &serde_json::Value, key: &str) -> Result<String, OpError> {
    v.get(key)
        .and_then(|x| x.as_str())
        .map(str::to_owned)
        .ok_or_else(|| OpError::body("propose_adr", format!("missing input '{key}'")))
}

fn validate_adr_id(id: &str) -> Result<(), OpError> {
    let parts: Vec<&str> = id.split('-').collect();
    if parts.len() != 2 || parts[0] != "adr" || parts[1].len() != 3 {
        return Err(OpError::body(
            "propose_adr",
            format!("adr id '{id}' must match adr-NNN (3-digit)"),
        ));
    }
    if !parts[1].chars().all(|c| c.is_ascii_digit()) {
        return Err(OpError::body(
            "propose_adr",
            format!("adr id '{id}' must end in 3 digits"),
        ));
    }
    Ok(())
}

fn validate_slug(slug: &str) -> Result<(), OpError> {
    let ok = !slug.is_empty()
        && slug
            .chars()
            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
    if ok {
        Ok(())
    } else {
        Err(OpError::body(
            "propose_adr",
            format!("slug '{slug}' must match [a-z0-9-]+"),
        ))
    }
}

fn render_adr_stub(id: &str, title: &str, decision: &str) -> String {
    let title_escaped = title.replace('\\', "\\\\").replace('"', "\\\"");
    let decision_escaped = decision.replace('\\', "\\\\").replace('"', "\\\"");
    format!(
        "# Generated by `propose_adr` op (O4). Edit via `transition_adr` once accepted.\n\n{{\n  \
         id     = \"{id}\",\n  title  = \"{title_escaped}\",\n  status = 'Proposed,\n  decision = \
         \"{decision_escaped}\",\n}}\n"
    )
}

/// Reject when the ondaod evaluation has no engaged tensions AND no
/// explicit empty-set rationale — the discipline requires one or the
/// other. Slice shape: see `runtime::load_ondaod_slice`.
#[onto_validator(
    id = "ondaod_engagement_declared",
    description = "Reject propose_adr when the ondaod evaluation declares neither engaged \
                   tensions nor an empty-set rationale.",
    category = "Contextual",
    slice_query = "ondaod"
)]
fn ondaod_engagement_declared(slice: &Slice, _ctx: &ValidationCtx) -> Verdict {
    let Some(ondaod) = slice.get("ondaod") else {
        return Verdict::reject(
            "slice missing 'ondaod' object",
            "ondaod_engagement_declared",
        );
    };
    let engaged = ondaod
        .get("engaged_tensions")
        .and_then(|v| v.as_array())
        .map(|arr| !arr.is_empty())
        .unwrap_or(false);
    let synthesis_state = ondaod
        .get("synthesis_state")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    if engaged || !synthesis_state.is_empty() {
        Verdict::Accept
    } else {
        Verdict::reject(
            "ondaod evaluation has neither engaged tensions nor a synthesis_state rationale",
            "ondaod_engagement_declared",
        )
    }
}

// Re-export the project_root resolution helper so tests can build a
// pathed context without copy-pasting boilerplate.
#[allow(dead_code)]
pub(crate) fn adr_path(root: &Path, id: &str, slug: &str) -> PathBuf {
    ontoref_ontology::layout::canonical_section(root, &format!("adrs/{id}-{slug}.ncl"))
}
