//! `UsersService` — the v1.1.8 data-plane user management contract.
//!
//! Scripts get `users::*` for CRUD on per-app end-users (distinct from
//! the control-plane operators in `admin_users`), session-based auth
//! (`login` / `verify` / `logout`), email verification, password reset,
//! invitations, and string-tagged per-app roles.
//!
//! Lives in `picloud-shared` so the Rhai bridge in `executor-core` and
//! the impl in `manager-core` share one trait. The impl
//! (`manager-core::users_service::UsersServiceImpl`) wires the
//! Postgres-backed repos; `picloud-shared` stays free of the sqlx
//! dependency.
//!
//! Every method that takes `&SdkCallCx` derives `app_id` from
//! `cx.app_id` — never from a script-passed argument. The
//! `verify_session_for_realtime` method takes `app_id` explicitly
//! because it's invoked from the realtime authority (no script
//! execution context).

use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;

use crate::{AppId, AppUserId, InvitationId, Principal, SdkCallCx};

/// Public app-user record returned by `get` / `find_by_email` / `verify`
/// / `list` / etc. Never carries the password hash. The `roles` field
/// is populated from `app_user_roles` (v1.1.8 commit 9 adds the storage;
/// pre-commit-9 it's always empty).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AppUser {
    pub id: AppUserId,
    pub app_id: AppId,
    pub email: String,
    pub display_name: Option<String>,
    pub email_verified_at: Option<DateTime<Utc>>,
    pub last_login_at: Option<DateTime<Utc>>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub roles: Vec<String>,
}

/// Pending invitation row returned by the admin invitations API.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invitation {
    pub id: InvitationId,
    pub app_id: AppId,
    pub email: String,
    pub display_name: Option<String>,
    pub roles: Vec<String>,
    pub created_at: DateTime<Utc>,
    pub expires_at: DateTime<Utc>,
}

/// Input for `users::create` and the admin POST endpoint.
#[derive(Debug, Clone)]
pub struct CreateUserInput {
    pub email: String,
    pub password: String,
    pub display_name: Option<String>,
}

/// Input for `users::update` / admin PATCH. v1.1.8 patches only
/// display_name; password changes go through password reset, email is
/// immutable post-create (a v1.2 concern).
#[derive(Debug, Clone, Default)]
pub struct UpdateUserInput {
    pub display_name: Option<Option<String>>,
}

/// Pagination knobs for `users::list`. The cursor is an opaque
/// `<rfc3339>_<uuid>` string from a previous page's `next_cursor`;
/// `None` is "first page". F-P-012: the id half is the tiebreaker that
/// prevents skip/duplicate at a created_at-equal page boundary.
#[derive(Debug, Clone, Default)]
pub struct UsersListOpts {
    pub cursor: Option<String>,
    pub limit: Option<i64>,
}

#[derive(Debug, Clone)]
pub struct UsersListPage {
    pub items: Vec<AppUser>,
    pub next_cursor: Option<String>,
}

/// Newly-minted session — returned exactly once by `users::login` and
/// `users::accept_invite`. The raw token goes back to the caller; only
/// the hash hits storage.
#[derive(Debug, Clone)]
pub struct GeneratedSession {
    pub token: String,
    pub user: AppUser,
    pub expires_at: DateTime<Utc>,
}

/// Outcome of `users::accept_invite`: the freshly-created user plus a
/// session token so the script can return both in one round-trip.
#[derive(Debug, Clone)]
pub struct GeneratedAccept {
    pub user: AppUser,
    pub session: GeneratedSession,
}

/// Email template fields supplied by the script to v1.1.8 email-tied
/// flows (`send_verification_email`, `request_password_reset`, `invite`).
/// The script owns the body; PiCloud only injects `?token=<token>` into
/// the `link_base`.
///
/// `from` is required because the underlying `EmailService::send` needs
/// an envelope sender — the brief example omitted it for brevity, but
/// the service can't synthesize a sensible default. Documented as a
/// shape deviation in HANDBACK §7.
#[derive(Debug, Clone)]
pub struct EmailTemplateOpts {
    pub link_base: String,
    pub from: String,
    pub subject: String,
    /// Body template with `{link}` substituted for the
    /// `link_base?token=<token>` URL. Plain text body — the v1.1.7
    /// hybrid email design lets richer templates land in userland.
    pub body_template: String,
}

/// Extra fields for `users::invite` on top of the email template:
/// optional pre-staged display name + roles applied on accept.
#[derive(Debug, Clone, Default)]
pub struct InviteOpts {
    pub template: Option<EmailTemplateOpts>,
    pub display_name: Option<String>,
    pub roles: Vec<String>,
}

/// Failure modes surfaced to the Rhai bridge and the admin HTTP layer.
/// Variants are mapped to stable string codes by the SDK bridge so
/// scripts can `try`/`catch` and switch on the code; admin handlers
/// map to HTTP status codes.
#[derive(Debug, Error)]
pub enum UsersError {
    /// Caller principal lacked the required capability. Only raised
    /// when `cx.principal.is_some()` (script-as-gate semantics).
    #[error("forbidden")]
    Forbidden,

    /// Looked-up user does not exist (or, more precisely, is not
    /// visible from `cx.app_id`).
    #[error("not found")]
    NotFound,

    /// `users::create` collided with an existing `(app_id, lower(email))`
    /// row. Surfaced to script-land as a distinguishable code so a
    /// sign-up form can react; never distinguishes "exists" vs
    /// "doesn't exist" through any timing-observable side channel.
    #[error("email already in use")]
    DuplicateEmail,

    /// Input field failed validation (e.g., empty email, password
    /// shorter than 8 characters, malformed shape).
    #[error("invalid: {0}")]
    Invalid(String),

    /// Email-tied flow attempted but no SMTP relay is configured. Maps
    /// to the same script-side code as `EmailError::NotConfigured` so
    /// scripts already handling the email-disabled mode don't need
    /// special cases.
    #[error("email is not configured")]
    EmailNotConfigured,

    /// Underlying email send failed (transport rejected, bad address,
    /// too large). Wraps the EmailError message for diagnostics.
    #[error("email transport: {0}")]
    EmailTransport(String),

    /// Email-tied surface (verification / password reset / invite) was
    /// rate-limited. Either the per-(app, recipient) burst limit or the
    /// per-app daily cap was exceeded. Surfaces silently to script-land
    /// in the reset path (to avoid an existence-leak side channel) but
    /// is raised explicitly to admin-mediated callers.
    #[error("email send rate-limited")]
    EmailRateLimited,

    /// Underlying storage error. Surfaces to script-land as a generic
    /// runtime error; the message is logged server-side for forensics.
    #[error("backend error: {0}")]
    Backend(String),
}

#[async_trait]
pub trait UsersService: Send + Sync {
    // ---- CRUD ----

    async fn create(&self, cx: &SdkCallCx, input: CreateUserInput) -> Result<AppUser, UsersError>;

    async fn get(&self, cx: &SdkCallCx, id: AppUserId) -> Result<Option<AppUser>, UsersError>;

    async fn find_by_email(
        &self,
        cx: &SdkCallCx,
        email: &str,
    ) -> Result<Option<AppUser>, UsersError>;

    /// Anonymous-safe existence probe for self-serve registration.
    /// Returns `true` when the email is free to register, `false` when
    /// it's taken. Unlike [`find_by_email`](Self::find_by_email) (which
    /// requires an authenticated principal to prevent user enumeration),
    /// this leaks only a single boolean — no id, profile, or timing
    /// distinction beyond "exists / doesn't" — so a public registration
    /// script can pre-check before calling `create`.
    ///
    /// Enumeration note: that bit is the same one a register form already
    /// leaks ("email taken"), but this probe is cheaper (no password hash),
    /// has no side effect (a `create` miss writes a junk row), and is not
    /// rate limited (there is no built-in throttle/CAPTCHA primitive). A
    /// script exposing it on an anonymous route inherits account-enumeration
    /// risk and must add its own `kv`-based throttle if that matters.
    async fn email_available(&self, cx: &SdkCallCx, email: &str) -> Result<bool, UsersError>;

    async fn update(
        &self,
        cx: &SdkCallCx,
        id: AppUserId,
        patch: UpdateUserInput,
    ) -> Result<AppUser, UsersError>;

    async fn delete(&self, cx: &SdkCallCx, id: AppUserId) -> Result<bool, UsersError>;

    async fn list(&self, cx: &SdkCallCx, opts: UsersListOpts) -> Result<UsersListPage, UsersError>;

    // ---- Auth ----

    /// Email + password login. Returns `Some(session)` on success,
    /// `None` on bad credentials (same wall-clock cost on bad-email
    /// vs bad-password via the timing-flat dummy Argon2 path).
    async fn login(
        &self,
        cx: &SdkCallCx,
        email: &str,
        password: &str,
    ) -> Result<Option<GeneratedSession>, UsersError>;

    /// Verify a session token. Returns the owning user on success,
    /// `None` on missing / expired / revoked / cross-app token. Bumps
    /// the sliding-window TTL on success (capped at the absolute cap).
    async fn verify(&self, cx: &SdkCallCx, token: &str) -> Result<Option<AppUser>, UsersError>;

    /// Logout — revokes the session. No-op for already-revoked /
    /// already-expired tokens.
    async fn logout(&self, cx: &SdkCallCx, token: &str) -> Result<(), UsersError>;

    /// Realtime SSE entry point. Same DB lookup + sliding-window bump
    /// as `verify`, but takes `app_id` explicitly because the
    /// `RealtimeAuthority` is not inside a script execution. Returns
    /// `None` on missing / expired / revoked / cross-app token.
    async fn verify_session_for_realtime(
        &self,
        app_id: AppId,
        token: &str,
    ) -> Result<Option<AppUser>, UsersError>;

    // ---- Email verification (commit 5) ----

    async fn send_verification_email(
        &self,
        cx: &SdkCallCx,
        user_id: AppUserId,
        opts: EmailTemplateOpts,
    ) -> Result<(), UsersError>;

    async fn verify_email(
        &self,
        cx: &SdkCallCx,
        token: &str,
    ) -> Result<Option<AppUser>, UsersError>;

    // ---- Password reset (commit 6) ----

    async fn request_password_reset(
        &self,
        cx: &SdkCallCx,
        email: &str,
        opts: EmailTemplateOpts,
    ) -> Result<(), UsersError>;

    async fn complete_password_reset(
        &self,
        cx: &SdkCallCx,
        token: &str,
        new_password: &str,
    ) -> Result<Option<AppUser>, UsersError>;

    // ---- Invitations (commit 7) ----

    async fn invite(&self, cx: &SdkCallCx, email: &str, opts: InviteOpts)
        -> Result<(), UsersError>;

    async fn accept_invite(
        &self,
        cx: &SdkCallCx,
        token: &str,
        password: &str,
        display_name: Option<String>,
    ) -> Result<Option<GeneratedAccept>, UsersError>;

    // ---- Roles (commit 8) ----

    async fn add_role(
        &self,
        cx: &SdkCallCx,
        user_id: AppUserId,
        role: &str,
    ) -> Result<(), UsersError>;

    async fn remove_role(
        &self,
        cx: &SdkCallCx,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, UsersError>;

    async fn has_role(
        &self,
        cx: &SdkCallCx,
        user_id: AppUserId,
        role: &str,
    ) -> Result<bool, UsersError>;

    // ---- Admin-mediated (no cx; called from the admin HTTP layer
    //      with an explicit principal) ----

    /// Admin clicks "reset password"; returns a one-shot token the
    /// admin can paste into a manual reset link. Does NOT send an
    /// email — that's the SDK's `request_password_reset` path.
    async fn admin_reset_password_token(
        &self,
        principal: &Principal,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<String, UsersError>;

    /// Admin clicks "revoke all sessions"; returns the count of
    /// sessions revoked (zero for already-quiet users).
    async fn admin_revoke_all_sessions(
        &self,
        principal: &Principal,
        app_id: AppId,
        user_id: AppUserId,
    ) -> Result<u64, UsersError>;

    /// Admin issues an invitation from the dashboard (distinct from
    /// the SDK `users::invite` path, which takes an SdkCallCx tied to
    /// the calling script). Returns the created invitation so the
    /// admin UI can render it without a re-fetch.
    async fn admin_create_invitation(
        &self,
        principal: &Principal,
        app_id: AppId,
        email: &str,
        opts: InviteOpts,
    ) -> Result<Invitation, UsersError>;

    /// Admin lists pending invitations for the app's invitations tab.
    async fn list_invitations(
        &self,
        principal: &Principal,
        app_id: AppId,
    ) -> Result<Vec<Invitation>, UsersError>;

    /// Admin revokes a pending invitation (the row is deleted; the
    /// already-mailed link becomes inert).
    async fn revoke_invitation(
        &self,
        principal: &Principal,
        app_id: AppId,
        invite_id: InvitationId,
    ) -> Result<bool, UsersError>;
}

/// Stub used by tests that build a `Services` bundle without a backing
/// users impl. Every call returns `UsersError::Backend("noop users
/// service")` so tests fail loudly instead of silently passing through.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopUsersService;

const NOOP_MSG: &str = "noop users service";

#[async_trait]
impl UsersService for NoopUsersService {
    async fn create(
        &self,
        _cx: &SdkCallCx,
        _input: CreateUserInput,
    ) -> Result<AppUser, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn get(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn find_by_email(
        &self,
        _cx: &SdkCallCx,
        _email: &str,
    ) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn email_available(&self, _cx: &SdkCallCx, _email: &str) -> Result<bool, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn update(
        &self,
        _cx: &SdkCallCx,
        _id: AppUserId,
        _patch: UpdateUserInput,
    ) -> Result<AppUser, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn delete(&self, _cx: &SdkCallCx, _id: AppUserId) -> Result<bool, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn list(
        &self,
        _cx: &SdkCallCx,
        _opts: UsersListOpts,
    ) -> Result<UsersListPage, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn login(
        &self,
        _cx: &SdkCallCx,
        _email: &str,
        _password: &str,
    ) -> Result<Option<GeneratedSession>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn verify(&self, _cx: &SdkCallCx, _token: &str) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn logout(&self, _cx: &SdkCallCx, _token: &str) -> Result<(), UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn verify_session_for_realtime(
        &self,
        _app_id: AppId,
        _token: &str,
    ) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn send_verification_email(
        &self,
        _cx: &SdkCallCx,
        _user_id: AppUserId,
        _opts: EmailTemplateOpts,
    ) -> Result<(), UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn verify_email(
        &self,
        _cx: &SdkCallCx,
        _token: &str,
    ) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn request_password_reset(
        &self,
        _cx: &SdkCallCx,
        _email: &str,
        _opts: EmailTemplateOpts,
    ) -> Result<(), UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn complete_password_reset(
        &self,
        _cx: &SdkCallCx,
        _token: &str,
        _new_password: &str,
    ) -> Result<Option<AppUser>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn invite(
        &self,
        _cx: &SdkCallCx,
        _email: &str,
        _opts: InviteOpts,
    ) -> Result<(), UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn accept_invite(
        &self,
        _cx: &SdkCallCx,
        _token: &str,
        _password: &str,
        _display_name: Option<String>,
    ) -> Result<Option<GeneratedAccept>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn add_role(
        &self,
        _cx: &SdkCallCx,
        _user_id: AppUserId,
        _role: &str,
    ) -> Result<(), UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn remove_role(
        &self,
        _cx: &SdkCallCx,
        _user_id: AppUserId,
        _role: &str,
    ) -> Result<bool, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn has_role(
        &self,
        _cx: &SdkCallCx,
        _user_id: AppUserId,
        _role: &str,
    ) -> Result<bool, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn admin_reset_password_token(
        &self,
        _principal: &Principal,
        _app_id: AppId,
        _user_id: AppUserId,
    ) -> Result<String, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn admin_revoke_all_sessions(
        &self,
        _principal: &Principal,
        _app_id: AppId,
        _user_id: AppUserId,
    ) -> Result<u64, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn admin_create_invitation(
        &self,
        _principal: &Principal,
        _app_id: AppId,
        _email: &str,
        _opts: InviteOpts,
    ) -> Result<Invitation, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn list_invitations(
        &self,
        _principal: &Principal,
        _app_id: AppId,
    ) -> Result<Vec<Invitation>, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
    async fn revoke_invitation(
        &self,
        _principal: &Principal,
        _app_id: AppId,
        _invite_id: InvitationId,
    ) -> Result<bool, UsersError> {
        Err(UsersError::Backend(NOOP_MSG.into()))
    }
}
