//! EIP **Outbound Channel Adapter** for the Fialucci chain — Allora-flavored.
//!
//! [`FialucciOracleAdapter`] is the endpoint of an oracle pipeline. A
//! preceding chain of EIP components (typically a Translator that produces
//! a typed claim payload from some source-specific event) writes an
//! [`OracleClaimPayload`] to a channel; this adapter consumes from that
//! channel, signs via the SDK's crate-private signing path, and POSTs to
//! the configured chain's `/oracle/submissions` endpoint. The pipeline ends
//! here — from the messaging system's perspective the message has left.
//!
//! ## Why "Outbound Channel Adapter" and not "Gateway"
//!
//! EIP-strict definitions (Hohpe/Woolf):
//! - A **Messaging Gateway** is the entry point — code *outside* the
//!   messaging system calls into it. Nothing on its outside.
//! - A **Channel Adapter** bridges the messaging system to an external
//!   system. Inbound adapters pull external data into messages; **outbound
//!   adapters consume messages and dispatch them outward**.
//!
//! This component consumes `OracleClaimPayload` (internal Fialucci wire
//! message), performs protocol-specific adaptation (signing — the only
//! place that knows the chain's canonical signing-bytes derivation), and
//! dispatches to an external system (the chain's HTTP API). Textbook
//! outbound channel adapter.
//!
//! ## Why a `Service` impl today and not an `OutboundAdapter` impl
//!
//! Allora's `OutboundAdapter` trait exists and matches the EIP semantics
//! perfectly. But Allora's YAML wiring today connects `OutboundAdapter`s
//! only via explicit application-code `.dispatch()` calls (see the
//! `http-outbound` example); it does not yet auto-wire an outbound adapter
//! to a channel as a consumer. Implementing as an Allora `Service` lets
//! the adapter be wired from `allora.yml` exactly like any other component
//! (`service-activators:` block with `from:`/`to:` channels). When Allora
//! grows YAML-wired `OutboundAdapter` support, this module will gain a
//! true `OutboundAdapter` impl alongside the `Service` impl with no API
//! breakage.

use allora::{service, Exchange, Payload, Result as AlloraResult, Service};
use async_trait::async_trait;
use fialucci_types::{ProofClaim, ProofDomain, ProofEvidence, ProofSubject};
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;

use crate::identity::OracleIdentity;
use crate::runtime::Oracle;

const DEFAULT_NODE_URL: &str = "http://127.0.0.1:8080";
const DEFAULT_KEY_FILE: &str = "./oracle.key";

/// The typed payload an upstream Translator hands to
/// [`FialucciOracleAdapter`]. Carried on the inbound channel as JSON in
/// `Message::body_text()` (until Allora gains typed messages, at which
/// point this becomes a typed channel value).
///
/// Every field is what a Fialucci oracle authority needs to assert
/// something on-chain. Identity, chain_id, nonce, and timestamp are
/// **not** here — those are filled in by the adapter at sign time, so
/// pipeline-side components stay decoupled from the oracle's identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OracleClaimPayload {
    /// Data domain (what kind of evidence this proof carries).
    pub domain: ProofDomain,
    /// What this submission refers to.
    pub subject: ProofSubject,
    /// What the oracle asserts about the subject.
    pub claim: ProofClaim,
    /// Evidence backing the claim.
    pub evidence: ProofEvidence,
}

impl OracleClaimPayload {
    /// Serialize to a JSON string for placement in `Message::body_text()`.
    /// Bridges to Allora's current untyped-payload model.
    pub fn to_json(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(self)
    }

    /// Deserialize from JSON text. Inverse of [`to_json`](Self::to_json).
    pub fn from_json(s: &str) -> Result<Self, serde_json::Error> {
        serde_json::from_str(s)
    }
}

/// EIP Outbound Channel Adapter for the Fialucci chain.
///
/// Lazy-initializes its [`Oracle`] handle on first dispatch — `Service::new`
/// is sync (inventory-registered at static-init time) but the SDK's
/// `Oracle::builder().build()` is async (discovers `chain_id` via
/// `GET /chain-info`). The `OnceCell` bridges that.
///
/// ## Configuration
///
/// Reads two environment variables at first-dispatch time:
///
/// | Env var | Default | Effect |
/// |---|---|---|
/// | `FIALUCCI_ORACLE_NODE_URL` | `http://127.0.0.1:8080` | Chain URL the adapter POSTs to. |
/// | `FIALUCCI_ORACLE_KEY_FILE` | `./oracle.key` | Oracle keystore JSON path. Auto-generated if missing. |
///
/// Env-var configuration is the v0.0.3 contract. When Allora gains
/// per-service `properties:` blocks in YAML, the adapter will read its
/// configuration from there instead.
#[derive(Debug)]
pub struct FialucciOracleAdapter {
    oracle: OnceCell<Oracle>,
}

#[service(name = "fialucci_oracle_adapter")]
impl FialucciOracleAdapter {
    /// Construct an uninitialized adapter. The inner `Oracle` is built
    /// on first dispatch.
    pub fn new() -> Self {
        Self {
            oracle: OnceCell::new(),
        }
    }
}

impl FialucciOracleAdapter {
    async fn oracle(&self) -> AlloraResult<&Oracle> {
        self.oracle
            .get_or_try_init(|| async {
                let node_url = std::env::var("FIALUCCI_ORACLE_NODE_URL")
                    .unwrap_or_else(|_| DEFAULT_NODE_URL.into());
                let key_file = std::env::var("FIALUCCI_ORACLE_KEY_FILE")
                    .unwrap_or_else(|_| DEFAULT_KEY_FILE.into());

                let identity = if std::path::Path::new(&key_file).exists() {
                    OracleIdentity::load_from_file(&key_file).map_err(|e| {
                        allora::Error::other(format!("load identity from {key_file}: {e}"))
                    })?
                } else {
                    let i = OracleIdentity::generate();
                    i.save_to_file(&key_file).map_err(|e| {
                        allora::Error::other(format!("save identity to {key_file}: {e}"))
                    })?;
                    eprintln!(
                        "fialucci_oracle_adapter: generated identity at {key_file}; oracle_id={}",
                        hex::encode(i.oracle_id().0)
                    );
                    i
                };

                let oracle = Oracle::builder()
                    .identity(identity)
                    .node_url(&node_url)
                    .handler(NoopHandler) // adapter uses submit_proactive; handler dispatch unused
                    .build()
                    .await
                    .map_err(|e| allora::Error::other(format!("build oracle: {e}")))?;

                eprintln!(
                    "fialucci_oracle_adapter: ready chain_id={}, oracle_id={}",
                    oracle.chain_id(),
                    hex::encode(oracle.oracle_id().0)
                );
                Ok(oracle)
            })
            .await
    }
}

#[async_trait]
impl Service for FialucciOracleAdapter {
    async fn process(&self, exchange: &mut Exchange) -> AlloraResult<()> {
        let body = exchange.in_msg.body_text().ok_or_else(|| {
            allora::Error::other(
                "fialucci_oracle_adapter: expected OracleClaimPayload JSON in message body, got empty/binary",
            )
        })?;

        let payload = OracleClaimPayload::from_json(body).map_err(|e| {
            allora::Error::other(format!(
                "fialucci_oracle_adapter: payload not OracleClaimPayload JSON: {e}"
            ))
        })?;

        let oracle = self.oracle().await?;
        let ack = oracle
            .submit_proactive(
                payload.domain,
                payload.subject,
                payload.claim,
                payload.evidence,
            )
            .await
            .map_err(|e| allora::Error::other(format!("submit_proactive: {e}")))?;

        let ack_json = serde_json::to_string(&AdapterAck {
            status: ack.status,
            proof_id: ack.proof_id,
            error: ack.error,
        })
        .map_err(|e| allora::Error::other(format!("serialize ack: {e}")))?;
        // Service-to-service chaining (the Allora convention): write the
        // transformed payload back onto `in_msg.payload`. `out_msg` is
        // reserved for HTTP-inbound reply paths (returning bytes to the
        // HTTP client); chained service-activators do NOT propagate
        // `out_msg`. Downstream consumers reading `in_msg.body_text()`
        // therefore receive the ack JSON.
        exchange.in_msg.payload = Payload::Text(ack_json);
        Ok(())
    }
}

/// The chain's submission ack, in the JSON shape the adapter writes to the
/// outbound channel. Downstream services can deserialize this with
/// [`OracleClaimPayload::from_json`]-style helpers if they need the data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdapterAck {
    /// `"accepted"`, `"duplicate"`, or `"rejected"`.
    pub status: String,
    /// Hex-encoded `OracleDataSubmissionId` (the proof's deterministic ID).
    pub proof_id: Option<String>,
    /// Free-form error message when `status == "rejected"`.
    pub error: Option<String>,
}

// ── NoopHandler ──────────────────────────────────────────────────────────────
//
// The SDK's `OracleBuilder::handler()` is required by the builder; the
// adapter doesn't dispatch through it (only `submit_proactive` is called).
// One-line stub.

use crate::handler::{HandlerError, ObservationRequest, OracleHandler, ProofClaimEvidence};

#[derive(Debug)]
struct NoopHandler;

#[async_trait]
impl OracleHandler for NoopHandler {
    async fn observe(
        &self,
        _request: &ObservationRequest,
    ) -> std::result::Result<Option<ProofClaimEvidence>, HandlerError> {
        Ok(None)
    }
}

// Quieter-than-default Default impl on Arc<dyn Service>-able types is not
// derive-able generically; this `Default` mirrors `new()` for callers that
// want the trait-bound shorthand.
impl Default for FialucciOracleAdapter {
    fn default() -> Self {
        Self::new()
    }
}

// ─────────────────────────────────────────────────────────────────────────────
//
// FialucciOracleSigner — the **sign-only** half of [`FialucciOracleAdapter`].
//
// Reads an `OracleClaimPayload` from the inbound channel, signs it via the
// SDK's crate-private path (no public `sign_bytes` ever), writes the
// fully-formed [`SignedOracleDataSubmission`] as JSON to `in_msg.payload`,
// and returns. Dispatch is **not** this service's job — the YAML wires the
// outbound channel to an Allora `http-outbound-adapter` that POSTs the
// signed body to a declarative chain URL.
//
// When to use which:
// - `fialucci_oracle_adapter` — when the chain URL lives in env and the
//   SDK should own both signing and dispatch (the original POC pattern).
// - `fialucci_oracle_signer` — when the chain URL lives in `allora.yml`
//   (the post-allora-0.0.8 pattern: signer Service → http-outbound-adapter
//   block).
//
// Both share the same lazy-init `Oracle` machinery (chain_id discovery,
// identity load) — the signer just calls `Oracle::sign_proactive` instead
// of `Oracle::submit_proactive`.

/// EIP **Message Translator + Enricher** (the signing half of an oracle
/// pipeline) for the Fialucci chain. Reads `OracleClaimPayload` from
/// the inbound channel; emits a [`SignedSubmissionEnvelope`] JSON on the
/// outbound channel for an Allora `http-outbound-adapter` to dispatch.
///
/// Allora `ref-name`: **`fialucci_oracle_signer`**.
///
/// ## Why a separate Service from `FialucciOracleAdapter`?
///
/// In the all-in-one adapter, signing and HTTP POST are fused inside one
/// `process()` call. The chain URL has to be configured via env because
/// the dispatch is hidden inside the SDK. Splitting signing out lets the
/// pipeline express dispatch declaratively (`http-outbound-adapter:` block
/// in `allora.yml`), with the chain URL in YAML where every other endpoint
/// lives.
///
/// ## Wire shape
///
/// The outbound payload is [`SignedSubmissionEnvelope`] JSON — a thin
/// wrapper around `fialucci_types::SignedOracleDataSubmission` so future
/// metadata can ride alongside the signed body without breaking
/// downstream consumers. Today the envelope is just `{"signed": <body>}`.
///
/// ## Configuration
///
/// Same as [`FialucciOracleAdapter`]: `FIALUCCI_ORACLE_NODE_URL` and
/// `FIALUCCI_ORACLE_KEY_FILE` env vars (read at first dispatch). The node
/// URL is used **only** for `chain_id` discovery via `GET /chain-info` —
/// the actual submission is the http-outbound-adapter's job; this service
/// never POSTs anywhere.
#[derive(Debug)]
pub struct FialucciOracleSigner {
    oracle: OnceCell<Oracle>,
}

#[service(name = "fialucci_oracle_signer")]
impl FialucciOracleSigner {
    /// Construct an uninitialized signer. The inner `Oracle` is built
    /// on first dispatch.
    pub fn new() -> Self {
        Self {
            oracle: OnceCell::new(),
        }
    }
}

impl FialucciOracleSigner {
    async fn oracle(&self) -> AlloraResult<&Oracle> {
        self.oracle
            .get_or_try_init(|| async {
                let node_url = std::env::var("FIALUCCI_ORACLE_NODE_URL")
                    .unwrap_or_else(|_| DEFAULT_NODE_URL.into());
                let key_file = std::env::var("FIALUCCI_ORACLE_KEY_FILE")
                    .unwrap_or_else(|_| DEFAULT_KEY_FILE.into());

                let identity = if std::path::Path::new(&key_file).exists() {
                    OracleIdentity::load_from_file(&key_file).map_err(|e| {
                        allora::Error::other(format!("load identity from {key_file}: {e}"))
                    })?
                } else {
                    let i = OracleIdentity::generate();
                    i.save_to_file(&key_file).map_err(|e| {
                        allora::Error::other(format!("save identity to {key_file}: {e}"))
                    })?;
                    eprintln!(
                        "fialucci_oracle_signer: generated identity at {key_file}; oracle_id={}",
                        hex::encode(i.oracle_id().0)
                    );
                    i
                };

                let oracle = Oracle::builder()
                    .identity(identity)
                    .node_url(&node_url)
                    .handler(NoopHandler)
                    .build()
                    .await
                    .map_err(|e| allora::Error::other(format!("build oracle: {e}")))?;

                eprintln!(
                    "fialucci_oracle_signer: ready chain_id={}, oracle_id={}",
                    oracle.chain_id(),
                    hex::encode(oracle.oracle_id().0)
                );
                Ok(oracle)
            })
            .await
    }
}

#[async_trait]
impl Service for FialucciOracleSigner {
    async fn process(&self, exchange: &mut Exchange) -> AlloraResult<()> {
        let body = exchange.in_msg.body_text().ok_or_else(|| {
            allora::Error::other(
                "fialucci_oracle_signer: expected OracleClaimPayload JSON in message body, got empty/binary",
            )
        })?;
        let payload = OracleClaimPayload::from_json(body).map_err(|e| {
            allora::Error::other(format!(
                "fialucci_oracle_signer: payload not OracleClaimPayload JSON: {e}"
            ))
        })?;

        let oracle = self.oracle().await?;
        let signed = oracle
            .sign_proactive(
                payload.domain,
                payload.subject,
                payload.claim,
                payload.evidence,
            )
            .await
            .map_err(|e| allora::Error::other(format!("sign_proactive: {e}")))?;

        let envelope_json = serde_json::to_string(&SignedSubmissionEnvelope { signed })
            .map_err(|e| allora::Error::other(format!("serialize envelope: {e}")))?;
        // Allora service-to-service chaining writes the transformed
        // payload back onto `in_msg.payload`. See the adapter's comment
        // on the same convention.
        exchange.in_msg.payload = Payload::Text(envelope_json);
        Ok(())
    }
}

impl Default for FialucciOracleSigner {
    fn default() -> Self {
        Self::new()
    }
}

/// Envelope around a [`fialucci_types::SignedOracleDataSubmission`] for
/// the signer's outbound channel.
///
/// A wrapper is used (rather than serializing the bare submission) so
/// the SDK can ride extra metadata alongside future revisions without
/// breaking the channel-side contract — e.g. a `submission_attempt_id`,
/// or a `chain_url_override` once we add per-message routing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SignedSubmissionEnvelope {
    /// The fully-formed signed body — what the http-outbound-adapter
    /// dispatches as the request payload.
    pub signed: fialucci_types::SignedOracleDataSubmission,
}

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

    #[test]
    fn oracle_claim_payload_round_trips_through_json() {
        let original = OracleClaimPayload {
            domain: ProofDomain::Github,
            subject: ProofSubject::ExternalId("github:foo/bar#42".into()),
            claim: ProofClaim::WorkCompleted,
            evidence: ProofEvidence {
                evidence_hash: hash_evidence_bytes(b"{\"merged\":true}"),
                evidence_uri: Some("https://github.com/foo/bar/pull/42".into()),
                content_type: Some("application/json".into()),
                metadata: None,
            },
        };
        let json = original.to_json().unwrap();
        let parsed = OracleClaimPayload::from_json(&json).unwrap();
        assert_eq!(format!("{parsed:?}"), format!("{original:?}"));
    }

    #[test]
    fn adapter_ack_serializes_minimally() {
        let ack = AdapterAck {
            status: "accepted".into(),
            proof_id: Some("abc".into()),
            error: None,
        };
        let s = serde_json::to_string(&ack).unwrap();
        // Strict shape check — downstream services may consume this.
        assert!(s.contains(r#""status":"accepted""#));
        assert!(s.contains(r#""proof_id":"abc""#));
    }

    /// The envelope JSON shape is the channel-side contract between the
    /// signer Service and a downstream Allora `http-outbound-adapter`.
    /// Lock the field name so the adapter knows where to look for the
    /// signed body it should POST.
    #[test]
    fn signed_submission_envelope_carries_signed_field_at_top_level() {
        use crate::OracleIdentity;
        use fialucci_types::{
            sign_oracle_data_submission, OracleDataSubmissionBody, ProofClaim, ProofDomain,
            ProofEvidence, ProofSubject,
        };
        let identity = OracleIdentity::generate();
        let body = OracleDataSubmissionBody {
            chain_id: "fialucci-test".into(),
            domain: ProofDomain::Manual,
            subject: ProofSubject::ExternalId("x".into()),
            claim: ProofClaim::WorkCompleted,
            evidence: ProofEvidence {
                evidence_hash: hash_evidence_bytes(b"e"),
                evidence_uri: None,
                content_type: None,
                metadata: None,
            },
            oracle_id: identity.oracle_id(),
            timestamp_ms: 0,
            nonce: 0,
            observation_request_id: None,
        };
        // We sign manually here (the signer Service does this via
        // Oracle::sign_proactive in real use); this test focuses on the
        // envelope's wire shape, not the runtime path.
        let signing_key = fialucci_types::OracleSigningKey({
            // Reach the underlying ed25519 SigningKey via reading the
            // identity's persisted seed (round-trip through save_to_file
            // would be cleaner, but for the test we just generate a fresh
            // one). The test only needs *some* valid signature; the
            // signed body's exact contents don't matter for the envelope
            // shape.
            ed25519_dalek::SigningKey::generate(&mut rand::rngs::OsRng)
        });
        let signed = sign_oracle_data_submission(body, &signing_key).unwrap();

        let envelope = SignedSubmissionEnvelope { signed };
        let json = serde_json::to_string(&envelope).unwrap();
        assert!(
            json.starts_with(r#"{"signed":"#),
            "envelope must start with `{{\"signed\":` so downstream adapters can deserialize it; \
             got: {json}"
        );
    }
}
