//! Wire protocol for souveraine-sessiond — the session authority daemon.
//!
//! One JSON object per line over a Unix socket in the user's runtime dir,
//! guarded `ok`/`reason` responses, same shape as machined's protocol. The
//! socket lives on the user tier (unlike machined's system-tier socket): the
//! session authority serves exactly one seat and dies with it.
//!
//! The handoff contract with the shell:
//!
//! 1. sessiond starts before the shell and takes ext-session-lock — the
//!    session is locked before any shell surface can exist.
//! 2. The shell connects, sends `shell_ready`, and KEEPS the connection
//!    open. That connection is the heartbeat; EOF means the shell died.
//! 3. sessiond releases by dropping its Wayland connection WITHOUT
//!    unlocking. The compositor keeps the session locked (abandoned-client
//!    state) and `misc:allow_session_lock_restore` lets the shell's own
//!    WlSessionLock take over. There is never an unlocked instant.
//! 4. The shell sends `locked_ack` once its lock surface is secure. If the
//!    ack does not arrive in time, sessiond takes the lock back.
//! 5. Heartbeat EOF at any point → sessiond retakes the lock immediately,
//!    whether or not the session was locked at the time. Fail closed.

#![allow(dead_code)]

use serde::{Deserialize, Serialize};

/// Socket path relative to `$XDG_RUNTIME_DIR`.
pub const SOCKET_RELPATH: &str = "souveraine/sessiond.sock";

/// Upper bound on one request line.
pub const MAX_REQUEST_BYTES: u64 = 16 * 1024;

/// PAM service for the fallback unlock surface. The shell's lock uses
/// quickshell's default (`login`); the dedicated file lets the PIN stack be
/// audited separately and is shipped as root-owned system config by the OS
/// overlay, never by this crate (same stance as `souveraine-stepup`).
pub const DEFAULT_PAM_SERVICE: &str = "souveraine-sessiond";

/// How long after releasing the lock we wait for the shell's `locked_ack`
/// before deciding the shell is broken and taking the lock back.
pub const LOCKED_ACK_TIMEOUT_SECS: u64 = 15;

/// Why a request was refused, in a form a caller can branch on.
///
/// Refusals were prose. A human reads "a live shell owns the session lock" and
/// knows what to do; an agent composing several verbs into one intent
/// (doctrine §13) cannot branch on a sentence. The reason stays — it is the
/// only thing that explains *this* refusal rather than its class — but the
/// code is what a chain reads.
///
/// The taxonomy is deliberately small. RedFlag's executor exit codes are the
/// precedent (`NET_LAYER_PLAN.md` cites them for the same reason): a fixed
/// vocabulary a caller can exhaust, not a growing list it must keep up with.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RefusalCode {
    /// The op does not exist, or the line did not parse as a request.
    /// Retrying is pointless; the caller is speaking a vocabulary this daemon
    /// does not have. `describe` is the cure.
    UnsupportedOp,
    /// The op exists and the arguments are wrong — out of range, mismatched,
    /// or self-contradictory. Retrying with the same arguments is pointless.
    InvalidArgument,
    /// The op and arguments are fine and the machine's current state forbids
    /// it. This is the one a chain most often wants: it means *not now*, and
    /// the state that forbade it is worth reading before deciding what next.
    RefusedByState,
    /// The caller may not do this. Reserved — nothing issues it yet, because
    /// caller identity is uid 1000 for everything (audit P3). It exists so
    /// that when capability tokens land, callers already branch on it.
    NotPermitted,
    /// A dependency this op needs is absent — no compositor, no PAM stack, no
    /// sensor. Not the caller's fault and possibly transient.
    Unavailable,
}

impl RefusalCode {
    pub fn as_str(self) -> &'static str {
        match self {
            RefusalCode::UnsupportedOp => "unsupported_op",
            RefusalCode::InvalidArgument => "invalid_argument",
            RefusalCode::RefusedByState => "refused_by_state",
            RefusalCode::NotPermitted => "not_permitted",
            RefusalCode::Unavailable => "unavailable",
        }
    }
}

/// One entry in the verb table `describe` returns.
///
/// Serialize-only: it is a const table on this side of the wire and JSON on the
/// other, so the borrowed `&'static` fields never need to come back.
#[derive(Debug, Clone, Serialize)]
pub struct VerbDoc {
    /// The `op` string, exactly as it goes on the wire.
    pub op: &'static str,
    /// True when the op changes something. A caller planning a chain needs to
    /// know which steps are reversible reads and which are not.
    pub mutates: bool,
    /// What it does, in one line.
    pub summary: &'static str,
    /// The refusal codes this op can return. A chain plans its branches from
    /// this rather than discovering them by being refused.
    pub refuses: &'static [RefusalCode],
    /// A minimal request line that parses. Knowing an op exists is not enough
    /// to call it — `panel` needs `on`, `sensor_input` needs a source and a
    /// matching value — and a caller should not have to discover required
    /// fields by being refused. The test round-trips every one of these, so an
    /// example that stops parsing fails the build rather than the agent.
    pub example: &'static str,
}

/// The verb table. Every `Request` variant appears here; a test enforces it.
///
/// This exists because the surface was not enumerable. An agent that owns the
/// device (doctrine §13) has to be able to ask what it may do rather than be
/// told, and a verb that is missing from this table is as unreachable as one
/// that was never written.
pub const VERBS: &[VerbDoc] = &[
    VerbDoc {
        op: "describe",
        mutates: false,
        summary: "this table — the verbs, which ones mutate, how each can refuse",
        refuses: &[],
        example: r#"{"op":"describe"}"#,
    },
    VerbDoc {
        op: "status",
        mutates: false,
        summary: "liveness and who currently owns the session lock",
        refuses: &[],
        example: r#"{"op":"status"}"#,
    },
    VerbDoc {
        op: "shell_ready",
        mutates: true,
        summary: "the shell announces itself; this connection becomes the heartbeat",
        refuses: &[RefusalCode::RefusedByState],
        example: r#"{"op":"shell_ready"}"#,
    },
    VerbDoc {
        op: "locked_ack",
        mutates: true,
        summary: "the shell's lock surface reached compositor-acknowledged secure",
        refuses: &[RefusalCode::RefusedByState],
        example: r#"{"op":"locked_ack"}"#,
    },
    VerbDoc {
        op: "lock",
        mutates: true,
        summary: "sessiond takes the session lock itself; refused while a live shell owns it",
        refuses: &[RefusalCode::RefusedByState, RefusalCode::Unavailable],
        example: r#"{"op":"lock"}"#,
    },
    VerbDoc {
        op: "device_state",
        mutates: false,
        summary: "the unified state machine: state, panel, evidence, confidence, health",
        refuses: &[],
        example: r#"{"op":"device_state"}"#,
    },
    VerbDoc {
        op: "sensor_input",
        mutates: true,
        summary: "report a sensor reading as evidence; never an authority (doctrine §9)",
        refuses: &[RefusalCode::InvalidArgument],
        example: r#"{"op":"sensor_input","source":"proximity","value":{"near":true}}"#,
    },
    VerbDoc {
        op: "input",
        mutates: true,
        summary: "real user input happened; resets the idle budget",
        refuses: &[],
        example: r#"{"op":"input","trigger":"touch"}"#,
    },
    VerbDoc {
        op: "panel",
        mutates: true,
        summary: "the DPMS executor reports the panel's real power state",
        refuses: &[],
        example: r#"{"op":"panel","on":false}"#,
    },
    VerbDoc {
        op: "forensic_log",
        mutates: false,
        summary: "recent trail entries from the in-memory buffer",
        refuses: &[],
        example: r#"{"op":"forensic_log","count":50}"#,
    },
    VerbDoc {
        op: "get_policy",
        mutates: false,
        summary: "read the timed policy — the Auto-Lock shaped settings",
        refuses: &[],
        example: r#"{"op":"get_policy"}"#,
    },
    VerbDoc {
        op: "set_policy",
        mutates: true,
        summary: "change the timed policy; persisted, so a setting survives restart",
        refuses: &[RefusalCode::InvalidArgument, RefusalCode::Unavailable],
        example: r#"{"op":"set_policy","lock_blank_after_secs":15}"#,
    },
];

/// A request plus the caller's declared intent.
///
/// The wire shape is unchanged — `{"op":"panel","on":false}` still parses,
/// because `intent` is optional and flattened alongside the tagged enum. A
/// caller composing several verbs into one decision (doctrine §13) sets it
/// once per verb and the trail joins the leaves back to the intent that
/// produced them: `{"op":"panel","on":false,"intent":"quiet the room"}`.
///
/// Declared, never verified. It is a label the caller supplies about itself,
/// so it is evidence in exactly the sense doctrine §9 means — useful for
/// reconstruction, never a basis for a decision. Nothing branches on it.
#[derive(Debug, Clone, Deserialize)]
pub struct Envelope {
    #[serde(flatten)]
    pub request: Request,
    #[serde(default)]
    pub intent: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum Request {
    /// The verb table — what this daemon can be asked, which ops mutate, and
    /// how each can refuse. A caller composing verbs reads this first.
    Describe,
    /// Liveness + who currently owns the session lock.
    Status,
    /// The shell announces itself. The connection carrying this request
    /// becomes the heartbeat. If sessiond is holding the lock, it releases
    /// (see module docs) and the shell must lock and then `locked_ack`.
    ShellReady,
    /// The shell's lock surface reached compositor-acknowledged `secure`.
    LockedAck,
    /// Ask sessiond to take the session lock itself. Refused while a live
    /// shell owns steady state — the shell's lock IPC is the front door.
    Lock,
    /// Full device state: the unified state machine's current state,
    /// sensor evidence confidence, and doze tier. Superset of Status.
    DeviceState,
    /// Report a sensor reading to the device state machine.
    /// The shell or a sensor daemon feeds proximity/accel/touch evidence.
    SensorInput(SensorInput),
    /// Real user input happened — touch, key, power button, dt2w wake.
    /// Resets the idle budget that the lock-blank rule counts against.
    /// This is the wire that lets the machine tell "the user is looking at
    /// the lock screen" from "the lock screen has been lit for ten minutes."
    Input { trigger: Option<InputTrigger> },
    /// The DPMS executor reports the panel's real power state. The machine
    /// keeps the panel as a field, not a state: "locked with the screen off"
    /// is not a doze tier, and doze (frozen apps, Wi-Fi save) is not a dark
    /// glance. Reported, never assumed — the executor owns the panel.
    Panel { on: bool },
    /// Query recent forensic log entries. Returns the last N entries
    /// from the in-memory forensic buffer for post-hoc analysis.
    ForensicLog { count: Option<usize> },
    /// Read the timed policy — the Auto-Lock shaped settings.
    GetPolicy,
    /// Change the timed policy. Every field is optional; omitted fields keep
    /// their current value. This is the seam the Settings control center reads
    /// and writes, so a control there is a view over the owning daemon rather
    /// than a switch that only looks like it did something (TASK-19's rule).
    SetPolicy {
        /// Seconds; `Some(0)` means never blank.
        lock_blank_after_secs: Option<u64>,
        lock_blank_after_held_secs: Option<u64>,
        dim_grace_secs: Option<u64>,
        evidence_ttl_secs: Option<u64>,
        dim_warning: Option<bool>,
        /// Seconds a pending blank waits for its lock ack before going dark
        /// anyway. Never 0 — a blank that waits for nothing is the ordering
        /// bug this field exists to close.
        lock_ack_budget_secs: Option<u64>,
        /// Seconds; `Some(0)` means never blank an unlocked session here.
        unlocked_blank_after_secs: Option<u64>,
    },
}

/// What produced a user input. Recorded so the forensic trail can tell a
/// deliberate power-button press from an accidental pocket touch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum InputTrigger {
    Touch,
    Key,
    PowerButton,
    DoubleTapToWake,
    Unknown,
}

/// A sensor reading fed to the device state machine.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensorInput {
    /// Which sensor produced this reading.
    pub source: SensorSource,
    /// The reading value.
    pub value: SensorValue,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorSource {
    Proximity,
    Accelerometer,
    Light,
    Touch,
}

impl SensorSource {
    /// The name this source is known by in the forensic trail and in logs.
    /// Matches the `evidence_fresh` / `sensor_health` keys so a reader can
    /// join them without a translation table.
    pub fn as_str(self) -> &'static str {
        match self {
            SensorSource::Proximity => "proximity",
            SensorSource::Accelerometer => "accel",
            SensorSource::Light => "light",
            SensorSource::Touch => "touch",
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SensorValue {
    /// Proximity: near or far.
    Near(bool),
    /// Accelerometer: moving or stationary.
    Moving(bool),
    /// Light: changing or stable.
    Changing(bool),
    /// Touch: active or inactive.
    Active(bool),
}

/// What sessiond currently is, as reported by `status`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phase {
    /// sessiond holds ext-session-lock and renders the fallback surface.
    Holding,
    /// Lock released to the shell; heartbeat live; ack not yet seen.
    AwaitingShellLock,
    /// Shell owns steady state (heartbeat live, ack seen).
    Released,
    /// No shell heartbeat and not holding (initial-lock disabled or the
    /// user unlocked at the fallback surface with no shell to hand off to).
    Idle,
}

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

    #[test]
    fn request_round_trips() {
        let req: Request = serde_json::from_str(r#"{"op":"shell_ready"}"#).unwrap();
        assert!(matches!(req, Request::ShellReady));
        let req: Request = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
        assert!(matches!(req, Request::Status));
    }

    #[test]
    fn every_request_variant_is_in_the_verb_table() {
        // The table is the vocabulary an agent composes over (doctrine §13).
        // A verb missing from it is as unreachable as one never written, so
        // this test is the thing that keeps them from drifting apart.
        //
        // Checked by round-tripping each op string through the deserializer:
        // if `describe` advertises an op the daemon cannot parse, the entry is
        // a lie, and if a variant is added without a table entry the count
        // below fails.
        for v in VERBS {
            let parsed: Result<Request, _> = serde_json::from_str(v.example);
            assert!(
                parsed.is_ok(),
                "describe advertises `{}` with example `{}`, which the daemon cannot parse: {:?}",
                v.op,
                v.example,
                parsed.err()
            );
        }

        // And the other direction: every variant must be advertised. Bump this
        // deliberately when a verb is added, having added its VerbDoc.
        assert_eq!(VERBS.len(), 12, "a Request variant was added without a VerbDoc");
    }

    #[test]
    fn the_envelope_is_backward_compatible() {
        // Every line written before intent existed must still parse, or the
        // shell and the reporters stop talking to the daemon on upgrade —
        // TASK-28's failure mode, caused by the fix for it.
        let e: Envelope = serde_json::from_str(r#"{"op":"status"}"#).unwrap();
        assert!(matches!(e.request, Request::Status));
        assert_eq!(e.intent, None);

        let e: Envelope =
            serde_json::from_str(r#"{"op":"panel","on":false,"intent":"quiet the room"}"#).unwrap();
        assert!(matches!(e.request, Request::Panel { on: false }));
        assert_eq!(e.intent.as_deref(), Some("quiet the room"));
    }

    #[test]
    fn a_refusal_code_survives_the_wire() {
        // A chain branches on the code, so it has to arrive intact.
        let raw = serde_json::to_string(&RefusalCode::RefusedByState).unwrap();
        assert_eq!(raw, r#""refused_by_state""#);
        let back: RefusalCode = serde_json::from_str(&raw).unwrap();
        assert_eq!(back, RefusalCode::RefusedByState);
    }

    #[test]
    fn phase_serializes_snake_case() {
        assert_eq!(
            serde_json::to_string(&Phase::AwaitingShellLock).unwrap(),
            r#""awaiting_shell_lock""#
        );
    }
}
