//! Convergence point for the three operation surfaces (MCP / HTTP / CLI).
//!
//! Every surface ultimately calls [`dispatch_op_request`] — this function
//! builds an [`OpContext`] from the request envelope (actor id, signing
//! seed, optional state/core/backlog snapshots), invokes
//! [`ontoref_ops::dispatch_op`], and serialises the resulting witness +
//! effects + metrics as JSON. ADR-024 requires the three surfaces to
//! produce equivalent witnesses for the same `(op_id, inputs)`; this
//! function is the contract.

#[cfg(feature = "substrate")]
use std::collections::HashMap;
#[cfg(feature = "substrate")]
use std::path::Path;
use std::path::PathBuf;
#[cfg(feature = "substrate")]
use std::sync::{Arc, Mutex, OnceLock};

use ed25519_dalek::SigningKey;
use ontoref_ontology::types::StateConfig;
#[cfg(feature = "substrate")]
use ontoref_oplog::OpLog;
use ontoref_ops::{dispatch_op, OpContext, OpError, PipelineResult};
use serde::{Deserialize, Serialize};

/// JSON envelope every surface decodes into. `inputs` is the op-specific
/// payload; the remaining fields configure the [`OpContext`].
#[derive(Debug, Deserialize)]
pub struct DispatchRequest {
    /// Op-specific inputs forwarded to the op body.
    pub inputs: serde_json::Value,
    /// Optional context override — when omitted, defaults to a fresh
    /// `developer` actor with no preloaded state.
    #[serde(default)]
    pub context: Option<ContextOverride>,
}

/// Caller-supplied context fields. All optional; defaults shipped by
/// [`ContextOverride::resolve`].
#[derive(Debug, Default, Deserialize)]
pub struct ContextOverride {
    /// Actor invoking the op. Defaults to `"developer"` when absent.
    #[serde(default)]
    pub actor_id: Option<String>,
    /// 32-byte signing-key seed in lowercase hex. Defaults to a fixed
    /// piloto seed when absent. Production deployments MUST supply
    /// the actor's real seed (ADR-005 + D26 actor-keys flow).
    #[serde(default)]
    pub signing_key_seed_hex: Option<String>,
    /// Project root for `render_paths` resolution.
    #[serde(default)]
    pub project_root: Option<PathBuf>,
    /// FSM state snapshot (`StateConfig`) — required by ops with
    /// `precondition = fsm_dimension_by_id`.
    #[serde(default)]
    pub state: Option<StateConfig>,
    /// Core ontology snapshot — required by ops touching
    /// `.ontology/core.ncl`.
    #[serde(default)]
    pub core: Option<serde_json::Value>,
    /// Backlog snapshot.
    #[serde(default)]
    pub backlog: Option<serde_json::Value>,
}

impl ContextOverride {
    /// Materialise an [`OpContext`] from the override, filling defaults
    /// when the caller omitted fields.
    pub fn resolve(self) -> Result<OpContext, DispatchSurfaceError> {
        let actor_id = self.actor_id.unwrap_or_else(|| "developer".to_owned());
        let seed = if let Some(hex) = self.signing_key_seed_hex {
            parse_seed_hex(&hex)?
        } else {
            // Piloto default — deterministic, NOT a production secret.
            [0x42u8; 32]
        };
        let signing_key = SigningKey::from_bytes(&seed);
        let mut ctx = OpContext::new_in_memory(actor_id, "", signing_key);
        if let Some(state) = self.state {
            ctx = ctx.with_state(state);
        }
        if let Some(core) = self.core {
            ctx = ctx.with_core(core);
        }
        if let Some(backlog) = self.backlog {
            ctx = ctx.with_backlog(backlog);
        }
        if let Some(root) = self.project_root {
            ctx = ctx.with_project_root(root);
        }
        Ok(ctx)
    }
}

fn parse_seed_hex(hex: &str) -> Result<[u8; 32], DispatchSurfaceError> {
    if hex.len() != 64 {
        return Err(DispatchSurfaceError::InvalidSeed {
            reason: format!("expected 64 hex chars, got {}", hex.len()),
        });
    }
    let mut out = [0u8; 32];
    for i in 0..32 {
        out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| {
            DispatchSurfaceError::InvalidSeed {
                reason: format!("non-hex byte at position {}", i * 2),
            }
        })?;
    }
    Ok(out)
}

/// JSON-serialisable witness shape returned to every surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DispatchResponse {
    /// Op id this witness attests.
    pub op_id: String,
    /// Witness shape declared on the op (e.g. `"state_transition"`).
    pub witness_shape: String,
    /// Hex-encoded state root.
    pub state_root_hex: String,
    /// Hex-encoded actor public key.
    pub actor_public_key_hex: String,
    /// Hex-encoded signature.
    pub signature_hex: String,
    /// Hex-encoded effects hash.
    pub effects_hash_hex: String,
    /// Declarative effects the op recorded.
    pub effects: Vec<ontoref_ops::EffectRecord>,
    /// Per-stage latency microseconds.
    pub metrics: ontoref_ops::PipelineMetrics,
}

/// Failures emitted by the surface dispatcher.
#[derive(Debug, thiserror::Error)]
pub enum DispatchSurfaceError {
    /// Pipeline error — op rejected, validator denied, render failed, etc.
    #[error("pipeline: {0}")]
    Pipeline(#[from] OpError),
    /// Signing-key seed could not be parsed.
    #[error("invalid signing-key seed: {reason}")]
    InvalidSeed {
        /// Human-readable parse failure.
        reason: String,
    },
    /// The project is at a substrate tier (its oplog file exists) but the
    /// oplog could not be opened. Failing here is deliberate: silently
    /// running in-memory would sign a witness and drop it, diverging proof
    /// from state for a project that expects persistence.
    #[cfg(feature = "substrate")]
    #[error("oplog unavailable for substrate persistence: {reason}")]
    OplogUnavailable {
        /// Path + underlying open error.
        reason: String,
    },
}

/// Process-global cache of opened oplogs, keyed by project root. redb
/// permits a single `Database` handle per file per process, so every
/// dispatch for a given project must share one handle — opening per
/// request would deadlock on the file lock.
#[cfg(feature = "substrate")]
fn oplog_cache() -> &'static Mutex<HashMap<PathBuf, Arc<OpLog>>> {
    static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<OpLog>>>> = OnceLock::new();
    CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Resolve the oplog for `project_root`. Returns `Ok(None)` when the
/// project is not at a substrate tier — the absence of
/// `.ontoref/substrate/oplog.redb` IS the tier-0 signal (slice 2a
/// bootstraps the file on the Tier0→Tier1 transition), so in-memory
/// dispatch is correct. Returns `Err` only when the file exists but
/// cannot be opened. The handle is opened under the cache lock to
/// serialise concurrent first-opens, then reused.
#[cfg(feature = "substrate")]
fn oplog_for(project_root: &Path) -> Result<Option<Arc<OpLog>>, DispatchSurfaceError> {
    let oplog_path = project_root
        .join(".ontoref")
        .join("substrate")
        .join("oplog.redb");
    if !oplog_path.exists() {
        return Ok(None);
    }
    // Recover from a poisoned lock rather than skip persistence silently.
    let mut cache = oplog_cache().lock().unwrap_or_else(|e| e.into_inner());
    if let Some(existing) = cache.get(&oplog_path) {
        return Ok(Some(Arc::clone(existing)));
    }
    let opened =
        Arc::new(
            OpLog::open(&oplog_path).map_err(|e| DispatchSurfaceError::OplogUnavailable {
                reason: format!("{}: {e}", oplog_path.display()),
            })?,
        );
    cache.insert(oplog_path, Arc::clone(&opened));
    Ok(Some(opened))
}

/// Single convergence function — every surface (MCP / HTTP / CLI)
/// routes through here. The three surfaces produce equivalent
/// [`DispatchResponse`] values for identical
/// `(op_id, inputs, context)` inputs.
///
/// # Errors
///
/// Pipeline failures from the underlying [`dispatch_op`] propagate
/// unchanged.
pub fn dispatch_op_request(
    op_id: &str,
    request: DispatchRequest,
) -> Result<DispatchResponse, DispatchSurfaceError> {
    let context = request.context.unwrap_or_default();
    let mut ctx = context.resolve()?;
    // Inject the per-project oplog when the project is at a substrate tier,
    // so the pipeline's final stage persists the witness (TASK 1). Keyed on
    // the resolved project root; a tier-0 project (no oplog file) stays
    // in-memory. This is what activates witness persistence in production.
    #[cfg(feature = "substrate")]
    if let Some(root) = ctx.project_root.clone() {
        if let Some(oplog) = oplog_for(&root)? {
            ctx = ctx.with_oplog(oplog);
        }
    }
    let result = dispatch_op(op_id, &request.inputs, &mut ctx)?;
    Ok(to_response(result))
}

fn to_response(result: PipelineResult) -> DispatchResponse {
    let witness = result.witness;
    DispatchResponse {
        op_id: witness.op_id,
        witness_shape: witness.witness_shape,
        state_root_hex: bytes_to_hex(&witness.state_root),
        actor_public_key_hex: bytes_to_hex(&witness.actor.0),
        signature_hex: bytes_to_hex(&witness.signature.0),
        effects_hash_hex: bytes_to_hex(&witness.effects_hash),
        effects: result.effects,
        metrics: result.metrics,
    }
}

fn bytes_to_hex(bytes: &[u8]) -> String {
    let mut out = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        use std::fmt::Write as _;
        let _ = write!(out, "{b:02x}");
    }
    out
}

/// Snapshot of every registered op's declarative shape — surfaces it
/// to the MCP `tools/list` response. Each entry is a `(name,
/// description, input_shape_hint)` triple.
pub fn ops_as_mcp_tools() -> Vec<McpOpTool> {
    inventory::iter::<ontoref_ops::OperationEntry>()
        .map(|e| McpOpTool {
            name: e.id.to_owned(),
            description: e.description.to_owned(),
            actor_policy: e.actor_policy.iter().map(|s| s.to_string()).collect(),
            pre_validators: e.pre_validators.iter().map(|s| s.to_string()).collect(),
            post_validators: e.post_validators.iter().map(|s| s.to_string()).collect(),
            render_paths: e.render_paths.iter().map(|s| s.to_string()).collect(),
            witness_shape: e.witness_shape.to_owned(),
        })
        .collect()
}

/// MCP-shaped projection of one [`ontoref_ops::OperationEntry`].
#[derive(Debug, Clone, Serialize)]
pub struct McpOpTool {
    /// Canonical op id — exposed to MCP clients as `tool.name`.
    pub name: String,
    /// One-line description — exposed as `tool.description`.
    pub description: String,
    /// Roles permitted to invoke. Surface for client-side gating.
    pub actor_policy: Vec<String>,
    /// Pre-validator ids that will block on Reject.
    pub pre_validators: Vec<String>,
    /// Post-validator ids that run after the body.
    pub post_validators: Vec<String>,
    /// NCL files regenerated post-op.
    pub render_paths: Vec<String>,
    /// Witness payload kind.
    pub witness_shape: String,
}

#[cfg(all(test, feature = "substrate"))]
mod substrate_tests {
    use super::*;

    /// A dispatch against a project whose oplog exists (substrate tier)
    /// must persist the signed witness — this is the production seam that
    /// activates TASK 1's append path through the daemon surface.
    #[test]
    fn dispatch_persists_witness_when_project_at_substrate_tier() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().to_path_buf();

        // Materialise the substrate oplog (what slice 2a's tier transition
        // does on Tier0→Tier1). Open + drop so the file exists and the
        // file lock is released before the dispatch cache opens it.
        let substrate_dir = root.join(".ontoref").join("substrate");
        std::fs::create_dir_all(&substrate_dir).expect("substrate dir");
        {
            let _ = OpLog::open(substrate_dir.join("oplog.redb")).expect("create oplog");
        }

        let before = oplog_for(&root)
            .expect("oplog resolves")
            .expect("oplog present at substrate tier")
            .len()
            .expect("len");
        assert_eq!(before, 0, "fresh oplog starts empty");

        let request = DispatchRequest {
            inputs: serde_json::json!({ "topic": "witness-seam", "synthesis_state": "held" }),
            context: Some(ContextOverride {
                actor_id: Some("developer".to_owned()),
                project_root: Some(root.clone()),
                ..Default::default()
            }),
        };
        let resp = dispatch_op_request("evaluate_ondaod", request).expect("dispatch ok");
        assert_eq!(resp.op_id, "evaluate_ondaod");

        let after = oplog_for(&root)
            .expect("oplog resolves")
            .expect("oplog present")
            .len()
            .expect("len");
        assert_eq!(after, 1, "dispatch must append exactly one witness");
    }

    /// A tier-0 project (no oplog file) dispatches in-memory — no oplog is
    /// created and the surface does not error.
    #[test]
    fn dispatch_stays_in_memory_when_no_substrate() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let root = tmp.path().to_path_buf();

        let request = DispatchRequest {
            inputs: serde_json::json!({ "topic": "tier-0" }),
            context: Some(ContextOverride {
                actor_id: Some("developer".to_owned()),
                project_root: Some(root.clone()),
                ..Default::default()
            }),
        };
        dispatch_op_request("evaluate_ondaod", request).expect("dispatch ok in-memory");

        assert!(
            oplog_for(&root).expect("resolves").is_none(),
            "no oplog file → tier-0 → in-memory, no persistence"
        );
        assert!(
            !root
                .join(".ontoref")
                .join("substrate")
                .join("oplog.redb")
                .exists(),
            "in-memory dispatch must not create an oplog"
        );
    }
}
