//! `parslee.capabilities` — read-only discovery of what the signed-in Parslee
//! account can do.
//!
//! Resolves the user's Parslee OAuth bearer (the same token CAR uses for
//! Parslee inference), then:
//!   - reads the account identity (id / email / active organization),
//!   - fetches the org's product entitlements from m365
//!     (`/api/v1/orgs/{orgId}/entitlements`), and
//!   - probes Studio (`studio.parslee.ai`) with the bearer to confirm the
//!     platform is reachable and accepts our token *right now*.
//!
//! It is the foundation every other Parslee capability tool gates on: a CAR
//! agent or the host UI calls this first to learn which products (studio, aie,
//! odi, crm, …) are enabled before offering a creative/document action. It
//! performs no writes and starts no jobs.
//!
//! Studio bearer auth shipped 2026-06-13 (studio `BearerSessionAuthenticationHandler`);
//! the probe here is what confirms that chain is live for this account.

use car_proto::ParsleeIdentity;
use serde_json::{json, Value};

use crate::parslee_auth::{self, ParsleeSession};

/// Default Studio host. Front Door stamps `X-Azure-FDID`; the raw container
/// FQDN is origin-guarded, so callers must use this host, not the container app.
const DEFAULT_STUDIO_BASE: &str = "https://studio.parslee.ai";
const STUDIO_BASE_ENV: &str = "PARSLEE_STUDIO_BASE";

/// Result of probing Studio with the user's bearer.
#[derive(Debug, Clone)]
struct StudioProbe {
    reachable: bool,
    /// `Some(true)` = an authenticated Studio endpoint accepted the bearer;
    /// `Some(false)` = it rejected it (401); `None` = couldn't determine
    /// (network error, or no active org to scope the probe).
    bearer_accepted: Option<bool>,
    status: Option<u16>,
    note: String,
}

pub(crate) fn api_base() -> String {
    car_secrets::resolve_env_or_keychain(parslee_auth::API_BASE_KEY)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| parslee_auth::DEFAULT_API_BASE.to_string())
}

/// A passed entitlement gate: the signed-in session plus the resolved active
/// org and API base, ready for a product API call. Returned by
/// [`gate_on_product`] so action tools (document generation, …) fail fast
/// before a doomed request.
pub(crate) struct Gate {
    pub session: ParsleeSession,
    pub org_id: String,
    pub api_base: String,
}

/// Resolve the Parslee session and assert `product` is enabled for the active
/// org. Returns a clear, user-facing error when not signed in, when the account
/// has no active org, or when the product isn't entitled — so capability tools
/// refuse early instead of making a request that would 403 (or silently cost
/// quota). The product id is one of [`car_proto`]-side well-knowns (`aie`,
/// `studio`, `odi`, …).
pub(crate) async fn gate_on_product(product: &str) -> Result<Gate, String> {
    let session = parslee_auth::load_or_refresh().await?.ok_or_else(|| {
        "not signed in to Parslee — run `car auth login` (or sign in via CAR Host.app)".to_string()
    })?;
    let org_id = session
        .identity
        .active_organization
        .clone()
        .filter(|s| !s.is_empty())
        .ok_or_else(|| {
            "the signed-in account has no active organization — finish onboarding via \
             CAR Host.app or the web"
                .to_string()
        })?;
    let base = api_base();
    let client = reqwest::Client::new();
    let products = enabled_products(&client, &base, &session.access_token, &org_id).await?;
    if !products.iter().any(|p| p.eq_ignore_ascii_case(product)) {
        return Err(format!(
            "the '{product}' product is not enabled for this organization \
             (enabled: {}). Enable it in Parslee, then retry — see \
             `car parslee capabilities`.",
            if products.is_empty() {
                "none".to_string()
            } else {
                products.join(", ")
            }
        ));
    }
    Ok(Gate {
        session,
        org_id,
        api_base: base,
    })
}

/// The org's enabled product ids (lowercased product slugs), via
/// [`fetch_entitlements`].
pub(crate) async fn enabled_products(
    client: &reqwest::Client,
    base: &str,
    bearer: &str,
    org_id: &str,
) -> Result<Vec<String>, String> {
    let summary = fetch_entitlements(client, base, bearer, org_id).await?;
    Ok(summary["enabled_products"]
        .as_array()
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(str::to_string))
                .collect()
        })
        .unwrap_or_default())
}

fn studio_base() -> String {
    car_secrets::resolve_env_or_keychain(STUDIO_BASE_ENV)
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| DEFAULT_STUDIO_BASE.to_string())
}

/// Handler for the `parslee.capabilities` JSON-RPC method.
pub async fn discover() -> Result<Value, String> {
    let session = match parslee_auth::load_or_refresh().await {
        Ok(Some(session)) => session,
        Ok(None) => {
            return Ok(json!({
                "authenticated": false,
                "hint": "no Parslee account signed in — run `car auth login` \
                         or sign in via CAR Host.app",
            }));
        }
        // A stored-but-unusable session (e.g. an expired refresh token) is the
        // same user-facing situation as "not signed in": re-authenticate. Report
        // it gracefully instead of surfacing a raw HTTP/RPC error, but keep the
        // underlying reason so transient failures are still diagnosable.
        Err(reason) => {
            return Ok(json!({
                "authenticated": false,
                "hint": "Parslee session could not be established — run `car auth login` \
                         to re-authenticate (or sign in via CAR Host.app)",
                "error": reason,
            }));
        }
    };

    let bearer = session.access_token.as_str();
    let identity = &session.identity;
    let base = api_base();
    let studio_base = studio_base();
    let org_id = identity
        .active_organization
        .as_deref()
        .filter(|s| !s.is_empty());

    let client = reqwest::Client::new();

    let entitlements = match org_id {
        Some(org) => fetch_entitlements(&client, &base, bearer, org).await,
        None => Err("the signed-in account has no active organization — finish \
                     onboarding via CAR Host.app or the web"
            .to_string()),
    };

    let studio = probe_studio(&client, &studio_base, bearer, org_id).await;

    Ok(build_capabilities(
        identity,
        entitlements,
        &studio,
        &studio_base,
    ))
}

/// Assemble the capability summary. Pure (no I/O) so it can be unit-tested:
/// `entitlements` is the result of the m365 call (Ok = normalized summary,
/// Err = a human-readable reason it's unavailable).
fn build_capabilities(
    identity: &ParsleeIdentity,
    entitlements: Result<Value, String>,
    studio: &StudioProbe,
    studio_base: &str,
) -> Value {
    let (entitlements_value, entitlements_error) = match entitlements {
        Ok(v) => (v, Value::Null),
        Err(e) => (Value::Null, json!(e)),
    };

    json!({
        "authenticated": true,
        "identity": {
            "account_id": identity.account_id,
            "email": identity.email,
            "display_name": identity.display_name,
            "active_organization": identity.active_organization,
            "organization_name": identity.organization_name,
        },
        "entitlements": entitlements_value,
        "entitlements_error": entitlements_error,
        "studio": {
            "host": studio_base,
            "reachable": studio.reachable,
            "bearer_accepted": studio.bearer_accepted,
            "probe_status": studio.status,
            "note": studio.note,
        },
    })
}

/// GET `/api/v1/orgs/{orgId}/entitlements` → normalized
/// `{ organization_id, enabled_products, products: [{product_id, enabled, tier, quotas}] }`.
pub(crate) async fn fetch_entitlements(
    client: &reqwest::Client,
    base: &str,
    bearer: &str,
    org_id: &str,
) -> Result<Value, String> {
    let url = format!(
        "{}/api/v1/orgs/{}/entitlements",
        base.trim_end_matches('/'),
        org_id
    );
    let resp = client
        .get(&url)
        .bearer_auth(bearer)
        .send()
        .await
        .map_err(|e| format!("Parslee entitlements request: {e}"))?;
    let status = resp.status();
    if !status.is_success() {
        let body = resp.text().await.unwrap_or_default();
        return Err(format!("Parslee entitlements: HTTP {status}: {body}"));
    }
    let raw: Value = resp
        .json()
        .await
        .map_err(|e| format!("parse Parslee entitlements: {e}"))?;
    Ok(normalize_entitlements(&raw, org_id))
}

/// Normalize the m365 `OrganizationEntitlementSummary` to a stable shape,
/// tolerant of camelCase/PascalCase field casing.
fn normalize_entitlements(raw: &Value, org_id: &str) -> Value {
    let products: Vec<Value> = ci(raw, "entitlements")
        .and_then(Value::as_array)
        .map(|arr| {
            arr.iter()
                .map(|e| {
                    json!({
                        "product_id": ci(e, "productId").or_else(|| ci(e, "product_id")).cloned(),
                        "enabled": ci(e, "enabled").and_then(Value::as_bool).unwrap_or(false),
                        "tier": ci(e, "tier").cloned(),
                        "quotas": ci(e, "quotas").cloned(),
                    })
                })
                .collect()
        })
        .unwrap_or_default();

    let enabled_products = ci(raw, "enabledProducts")
        .or_else(|| ci(raw, "enabled_products"))
        .cloned()
        .unwrap_or_else(|| json!([]));

    json!({
        "organization_id": ci(raw, "organizationId")
            .and_then(Value::as_str)
            .unwrap_or(org_id),
        "enabled_products": enabled_products,
        "products": products,
    })
}

/// Case-insensitive single-key lookup on a JSON object.
pub(crate) fn ci<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
    let obj = v.as_object()?;
    if let Some(found) = obj.get(key) {
        return Some(found);
    }
    obj.iter()
        .find(|(k, _)| k.eq_ignore_ascii_case(key))
        .map(|(_, val)| val)
}

/// Probe Studio. With an active org we hit an authenticated endpoint
/// (`/api/v1/orgs/{org}/studio/quota/me`) so the status distinguishes
/// "bearer accepted" (2xx/403) from "bearer rejected" (401). Without an org
/// we fall back to `/health` (reachability only).
async fn probe_studio(
    client: &reqwest::Client,
    studio_base: &str,
    bearer: &str,
    org_id: Option<&str>,
) -> StudioProbe {
    let base = studio_base.trim_end_matches('/');

    let url = match org_id {
        Some(org) => format!("{base}/api/v1/orgs/{org}/studio/quota/me"),
        None => format!("{base}/health"),
    };

    let mut req = client.get(&url);
    if org_id.is_some() {
        req = req.bearer_auth(bearer);
    }

    match req.send().await {
        Ok(resp) => {
            let status = resp.status();
            let code = status.as_u16();
            // 401 from the authed probe = the bearer chain is broken
            // (token → Studio Bearer handler → m365 /connect/session).
            // 403 = bearer accepted but this account lacks Studio access here.
            let bearer_accepted = if org_id.is_none() {
                None
            } else if code == 401 {
                Some(false)
            } else {
                Some(true)
            };
            let note = match (org_id.is_some(), code) {
                (true, 401) => "Studio rejected the bearer — the token→Studio→m365 \
                                auth chain is not accepting this token"
                    .to_string(),
                (true, 403) => "Studio accepted the bearer; this account has no Studio \
                                access in the active organization"
                    .to_string(),
                (true, c) if (200..300).contains(&c) => {
                    "Studio reachable and the bearer is accepted".to_string()
                }
                (true, c) => format!("Studio reachable; unexpected status {c}"),
                (false, c) if (200..300).contains(&c) => {
                    "Studio reachable (health only — no active org to probe authed access)"
                        .to_string()
                }
                (false, c) => format!("Studio health probe returned {c}"),
            };
            StudioProbe {
                reachable: true,
                bearer_accepted,
                status: Some(code),
                note,
            }
        }
        Err(e) => StudioProbe {
            reachable: false,
            bearer_accepted: None,
            status: None,
            note: format!("Studio unreachable at {base}: {e}"),
        },
    }
}

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

    fn identity() -> ParsleeIdentity {
        ParsleeIdentity {
            account_id: "acc_123".into(),
            email: Some("user@example.com".into()),
            display_name: Some("Studio User".into()),
            active_organization: Some("org_456".into()),
            organization_name: Some("Studio Org".into()),
        }
    }

    fn probe_ok() -> StudioProbe {
        StudioProbe {
            reachable: true,
            bearer_accepted: Some(true),
            status: Some(200),
            note: "ok".into(),
        }
    }

    #[test]
    fn normalizes_entitlements_camel_and_pascal() {
        let camel = json!({
            "organizationId": "org_456",
            "enabledProducts": ["studio", "aie"],
            "entitlements": [
                { "productId": "studio", "enabled": true, "tier": "Pro", "quotas": {"videos": 10} },
                { "productId": "crm", "enabled": false, "tier": "Standard" }
            ]
        });
        let n = normalize_entitlements(&camel, "org_456");
        assert_eq!(n["organization_id"], "org_456");
        assert_eq!(n["enabled_products"], json!(["studio", "aie"]));
        assert_eq!(n["products"][0]["product_id"], "studio");
        assert_eq!(n["products"][0]["enabled"], true);
        assert_eq!(n["products"][0]["tier"], "Pro");
        assert_eq!(n["products"][0]["quotas"]["videos"], 10);
        assert_eq!(n["products"][1]["enabled"], false);

        // PascalCase variant resolves identically.
        let pascal = json!({
            "OrganizationId": "org_456",
            "EnabledProducts": ["studio"],
            "Entitlements": [ { "ProductId": "studio", "Enabled": true, "Tier": "Pro" } ]
        });
        let np = normalize_entitlements(&pascal, "org_456");
        assert_eq!(np["organization_id"], "org_456");
        assert_eq!(np["products"][0]["product_id"], "studio");
        assert_eq!(np["products"][0]["enabled"], true);
    }

    #[test]
    fn build_capabilities_surfaces_identity_and_studio() {
        let ent = Ok(normalize_entitlements(
            &json!({"organizationId":"org_456","enabledProducts":["studio"],"entitlements":[]}),
            "org_456",
        ));
        let v = build_capabilities(&identity(), ent, &probe_ok(), DEFAULT_STUDIO_BASE);
        assert_eq!(v["authenticated"], true);
        assert_eq!(v["identity"]["account_id"], "acc_123");
        assert_eq!(v["identity"]["active_organization"], "org_456");
        assert_eq!(v["entitlements"]["enabled_products"], json!(["studio"]));
        assert_eq!(v["entitlements_error"], Value::Null);
        assert_eq!(v["studio"]["host"], DEFAULT_STUDIO_BASE);
        assert_eq!(v["studio"]["bearer_accepted"], true);
        assert_eq!(v["studio"]["probe_status"], 200);
    }

    #[test]
    fn build_capabilities_reports_entitlements_error_without_failing() {
        let v = build_capabilities(
            &identity(),
            Err("HTTP 403: forbidden".into()),
            &probe_ok(),
            DEFAULT_STUDIO_BASE,
        );
        assert_eq!(v["authenticated"], true);
        assert_eq!(v["entitlements"], Value::Null);
        assert_eq!(v["entitlements_error"], "HTTP 403: forbidden");
        // Studio probe still reported.
        assert_eq!(v["studio"]["reachable"], true);
    }
}
