//! Dynamic Client Registration (RFC 7591) primitives.
//!
//! Some MCP servers expose a `registration_endpoint` in their
//! `.well-known/oauth-authorization-server` metadata. When set,
//! `mcptest login` posts the client metadata once, caches the returned
//! `client_id` (and optional `client_secret`) next to the OAuth token,
//! and reuses both on subsequent logins. This module provides the
//! request type, the response parser, and the cache shape; the
//! `mcptest login` command wires them together.

use std::fs::{File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;

use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;

use super::token::TokenCache;

/// Per-request timeout for the registration call. Matches the token
/// exchange budget; DCR is a single short POST.
const REGISTER_TIMEOUT: Duration = Duration::from_secs(15);

/// Errors raised by the DCR layer.
///
/// The OAuth error codes RFC 7591 §3.2.2 defines (`invalid_redirect_uri`,
/// `invalid_client_metadata`, etc.) surface as [`DcrError::OAuth`] so
/// callers can pattern-match without parsing the description string.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DcrError {
    /// `registration_endpoint` did not parse as a URL.
    #[error("invalid registration endpoint: {reason}")]
    InvalidEndpoint {
        /// Human-readable reason.
        reason: String,
    },

    /// Network failure before a response was available.
    #[error("registration network failure (retryable={retryable}): {reason}")]
    Network {
        /// Operator-facing reason.
        reason: String,
        /// True for network errors and 5xx responses.
        retryable: bool,
    },

    /// Server returned an RFC 7591 §3.2.2 error response.
    #[error("registration endpoint returned `{code}`: {description}")]
    OAuth {
        /// Machine-readable RFC 7591 §3.2.2 error code.
        code: String,
        /// Optional `error_description` field.
        description: String,
    },

    /// 2xx response whose body did not parse as a registration result.
    #[error("malformed registration response: {reason}")]
    Malformed {
        /// Parser message.
        reason: String,
    },

    /// Filesystem error reading or writing the per-server client cache.
    #[error("io error on {}: {source}", path.display())]
    Io {
        /// Path that triggered the error.
        path: PathBuf,
        /// Underlying I/O error.
        #[source]
        source: std::io::Error,
    },
}

/// Request body for the POST to `registration_endpoint` per RFC 7591 §2.
///
/// Public clients (PKCE flow, no `client_secret`) set
/// `token_endpoint_auth_method` to `"none"`; confidential clients can
/// override the field when they roll their own secret-bearing recipe.
#[derive(Debug, Clone, Serialize)]
pub struct RegisterClientRequest {
    /// Human-readable client name surfaced in IdP consent screens.
    pub client_name: String,
    /// Allowed redirect URIs. RFC 7591 requires at least one for
    /// redirect-based grants; the device grant (WOR-1286) uses none, so
    /// an empty list is omitted from the wire format entirely.
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub redirect_uris: Vec<String>,
    /// OAuth grant types the client uses. Defaults to authorization
    /// code + refresh per the OAuth 2.1 + PKCE recipe.
    pub grant_types: Vec<String>,
    /// Token endpoint auth method. Defaults to `"none"` for public
    /// clients.
    pub token_endpoint_auth_method: String,
}

impl RegisterClientRequest {
    /// Build the default public-client request for `mcptest login`. The
    /// authorization-code + refresh grant types align with  and
    /// the refresh layer.
    pub fn public_client(client_name: impl Into<String>, redirect_uri: impl Into<String>) -> Self {
        Self {
            client_name: client_name.into(),
            redirect_uris: vec![redirect_uri.into()],
            grant_types: vec!["authorization_code".into(), "refresh_token".into()],
            token_endpoint_auth_method: "none".into(),
        }
    }

    /// Build the public-client request for `mcptest login --device`
    /// (WOR-1286): the RFC 8628 device-code grant plus refresh, no
    /// redirect URIs (the grant has no redirect leg), auth method
    /// `"none"`. Note the [`ClientCache`] is keyed by server URL only,
    /// so a client registered earlier for the PKCE flow may lack the
    /// device grant; the login error hint tells the user to re-run with
    /// `--force` to register a fresh client.
    pub fn device_public_client(client_name: impl Into<String>) -> Self {
        Self {
            client_name: client_name.into(),
            redirect_uris: Vec::new(),
            grant_types: vec![
                "urn:ietf:params:oauth:grant-type:device_code".into(),
                "refresh_token".into(),
            ],
            token_endpoint_auth_method: "none".into(),
        }
    }
}

/// Successful response body from the registration endpoint.
///
/// The IdP may also return additional fields (`client_id_issued_at`,
/// `client_secret_expires_at`, `registration_access_token`, etc.); we
/// retain the ones `mcptest login` actually consumes and let the rest
/// pass through via `#[serde(default)]`-flagged optional fields so a
/// stricter `deny_unknown_fields` does not bite us on benign extras.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RegisterClientResponse {
    /// `client_id` the IdP assigned to this registration.
    pub client_id: String,
    /// Optional `client_secret` (omitted for public PKCE clients).
    #[serde(default)]
    pub client_secret: Option<String>,
    /// UNIX timestamp when the `client_id` was issued.
    #[serde(default)]
    pub client_id_issued_at: Option<u64>,
    /// UNIX timestamp when `client_secret` expires. `0` per RFC 7591
    /// §3.2.1 means "never".
    #[serde(default)]
    pub client_secret_expires_at: Option<u64>,
}

#[derive(Deserialize)]
struct ErrorResponse {
    error: String,
    #[serde(default)]
    error_description: Option<String>,
}

/// POST `request` to `registration_endpoint` and return the parsed
/// response. Network failures and RFC 7591 §3.2.2 errors surface as
/// typed [`DcrError`] variants; the caller decides whether to retry or
/// abort.
pub fn register_client(
    registration_endpoint: &str,
    request: &RegisterClientRequest,
) -> Result<RegisterClientResponse, DcrError> {
    let client = Client::builder()
        .timeout(REGISTER_TIMEOUT)
        .build()
        .map_err(|e| DcrError::Network {
            reason: format!("reqwest client construction failed: {e}"),
            retryable: false,
        })?;
    register_with_client(&client, registration_endpoint, request)
}

/// Variant of [`register_client`] that accepts a pre-built client. Tests
/// point the client at a `wiremock` instance to exercise the wire path
/// without rebuilding the TLS stack per case.
pub fn register_with_client(
    client: &Client,
    registration_endpoint: &str,
    request: &RegisterClientRequest,
) -> Result<RegisterClientResponse, DcrError> {
    url::Url::parse(registration_endpoint).map_err(|e| DcrError::InvalidEndpoint {
        reason: format!("{registration_endpoint}: {e}"),
    })?;

    let resp = client
        .post(registration_endpoint)
        .json(request)
        .send()
        .map_err(|e| DcrError::Network {
            reason: format!("network error: {e}"),
            retryable: e.is_timeout() || e.is_connect(),
        })?;

    let status = resp.status();
    let body = resp.text().unwrap_or_default();
    if status.is_success() {
        return serde_json::from_str::<RegisterClientResponse>(&body).map_err(|e| {
            DcrError::Malformed {
                reason: e.to_string(),
            }
        });
    }

    if let Ok(err) = serde_json::from_str::<ErrorResponse>(&body) {
        return Err(DcrError::OAuth {
            code: err.error,
            description: err.error_description.unwrap_or_default(),
        });
    }
    Err(DcrError::OAuth {
        code: if status.is_server_error() {
            "server_error".into()
        } else {
            "invalid_request".into()
        },
        description: format!("HTTP {}: {}", status.as_u16(), truncate(&body, 200)),
    })
}

/// On-disk cache for the DCR result, keyed by the same SHA-256 of the
/// server URL the token cache uses, with a `.client.json` suffix so the
/// client metadata sits next to the access token without colliding.
///
/// Reuses [`TokenCache`] for the key-derivation helper and the cache
/// root resolution so two stores stay aligned.
pub struct ClientCache<'a> {
    token_cache: &'a TokenCache,
}

impl<'a> ClientCache<'a> {
    /// Wrap a [`TokenCache`] so DCR results sit beside the access
    /// tokens for the same server.
    pub fn new(token_cache: &'a TokenCache) -> Self {
        Self { token_cache }
    }

    /// Path the per-server client metadata lives at.
    fn client_path(&self, server_url: &str) -> PathBuf {
        let key = self.token_cache.key_for(server_url);
        self.token_cache.root().join(format!("{key}.client.json"))
    }

    /// Read the cached client metadata for `server_url`. Returns
    /// `Ok(None)` when no cache file exists yet.
    pub fn read(&self, server_url: &str) -> Result<Option<RegisterClientResponse>, DcrError> {
        let path = self.client_path(server_url);
        let mut file = match File::open(&path) {
            Ok(f) => f,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
            Err(e) => return Err(DcrError::Io { path, source: e }),
        };
        let mut buf = String::new();
        file.read_to_string(&mut buf).map_err(|e| DcrError::Io {
            path: path.clone(),
            source: e,
        })?;
        let parsed: RegisterClientResponse =
            serde_json::from_str(&buf).map_err(|e| DcrError::Malformed {
                reason: e.to_string(),
            })?;
        Ok(Some(parsed))
    }

    /// Write the client metadata atomically (tmp + rename) and chmod
    /// 0600 on Unix.
    pub fn write(
        &self,
        server_url: &str,
        response: &RegisterClientResponse,
    ) -> Result<(), DcrError> {
        let final_path = self.client_path(server_url);
        let dir = final_path.parent().ok_or_else(|| DcrError::Io {
            path: final_path.clone(),
            source: std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "client cache path has no parent dir",
            ),
        })?;
        std::fs::create_dir_all(dir).map_err(|e| DcrError::Io {
            path: dir.to_path_buf(),
            source: e,
        })?;

        let key = self.token_cache.key_for(server_url);
        let tmp_path = dir.join(format!("{key}.client.tmp"));
        let bytes = serde_json::to_vec_pretty(response).map_err(|e| DcrError::Malformed {
            reason: e.to_string(),
        })?;
        write_tmp(&tmp_path, &bytes)?;
        set_owner_only(&tmp_path)?;
        std::fs::rename(&tmp_path, &final_path).map_err(|e| DcrError::Io {
            path: final_path,
            source: e,
        })?;
        Ok(())
    }
}

fn write_tmp(tmp_path: &Path, bytes: &[u8]) -> Result<(), DcrError> {
    let mut tmp = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(tmp_path)
        .map_err(|e| DcrError::Io {
            path: tmp_path.to_path_buf(),
            source: e,
        })?;
    tmp.write_all(bytes).map_err(|e| DcrError::Io {
        path: tmp_path.to_path_buf(),
        source: e,
    })?;
    tmp.sync_all().map_err(|e| DcrError::Io {
        path: tmp_path.to_path_buf(),
        source: e,
    })?;
    Ok(())
}

#[cfg(unix)]
fn set_owner_only(path: &Path) -> Result<(), DcrError> {
    use std::os::unix::fs::PermissionsExt;
    let perms = std::fs::Permissions::from_mode(0o600);
    std::fs::set_permissions(path, perms).map_err(|e| DcrError::Io {
        path: path.to_path_buf(),
        source: e,
    })
}

#[cfg(not(unix))]
fn set_owner_only(_path: &Path) -> Result<(), DcrError> {
    Ok(())
}

fn truncate(s: &str, max: usize) -> String {
    s.chars().take(max).collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;
    use tempfile::TempDir;
    use wiremock::matchers::{body_partial_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    fn rt() -> tokio::runtime::Runtime {
        tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .unwrap()
    }

    #[test]
    fn public_client_request_uses_authorization_code_and_refresh() {
        let r = RegisterClientRequest::public_client("mcptest", "http://127.0.0.1:0/callback");
        assert_eq!(r.token_endpoint_auth_method, "none");
        assert_eq!(
            r.grant_types,
            vec![
                "authorization_code".to_string(),
                "refresh_token".to_string()
            ]
        );
        assert_eq!(r.redirect_uris.len(), 1);
    }

    #[test]
    fn device_public_client_requests_device_grant_without_redirects() {
        let r = RegisterClientRequest::device_public_client("mcptest");
        assert_eq!(r.token_endpoint_auth_method, "none");
        assert_eq!(
            r.grant_types,
            vec![
                "urn:ietf:params:oauth:grant-type:device_code".to_string(),
                "refresh_token".to_string()
            ]
        );
        assert!(r.redirect_uris.is_empty());
        // The empty list must vanish from the wire format: some IdPs
        // reject `redirect_uris: []` for non-redirect grants.
        let json = serde_json::to_value(&r).unwrap();
        assert!(json.get("redirect_uris").is_none());
    }

    #[test]
    fn register_round_trips_through_wiremock() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/register"))
                .and(body_partial_json(serde_json::json!({
                    "client_name": "mcptest",
                    "token_endpoint_auth_method": "none"
                })))
                .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
                    "client_id": "client-xyz",
                    "client_id_issued_at": 1_700_000_000u64
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = RegisterClientRequest::public_client("mcptest", "http://127.0.0.1:9000/callback");
        let resp = register_client(&format!("{uri}/register"), &req).unwrap();
        assert_eq!(resp.client_id, "client-xyz");
        assert_eq!(resp.client_secret, None);
        assert_eq!(resp.client_id_issued_at, Some(1_700_000_000));
    }

    #[test]
    fn register_maps_invalid_redirect_uri() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/register"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "invalid_redirect_uri",
                    "error_description": "redirect_uri must be loopback"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = RegisterClientRequest::public_client("mcptest", "http://10.0.0.1/callback");
        let err = register_client(&format!("{uri}/register"), &req).unwrap_err();
        match err {
            DcrError::OAuth { code, description } => {
                assert_eq!(code, "invalid_redirect_uri");
                assert!(description.contains("loopback"));
            }
            other => panic!("expected invalid_redirect_uri, got {other:?}"),
        }
    }

    #[test]
    fn register_maps_invalid_client_metadata() {
        let runtime = rt();
        let (uri, _guard) = runtime.block_on(async {
            let server = MockServer::start().await;
            Mock::given(method("POST"))
                .and(path("/register"))
                .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
                    "error": "invalid_client_metadata"
                })))
                .mount(&server)
                .await;
            (server.uri(), server)
        });

        let req = RegisterClientRequest::public_client("mcptest", "http://127.0.0.1:0/callback");
        let err = register_client(&format!("{uri}/register"), &req).unwrap_err();
        match err {
            DcrError::OAuth { code, .. } => assert_eq!(code, "invalid_client_metadata"),
            other => panic!("expected invalid_client_metadata, got {other:?}"),
        }
    }

    #[test]
    fn client_cache_round_trips_a_response() {
        let dir = TempDir::new().unwrap();
        let tc = TokenCache::with_root(dir.path());
        let cc = ClientCache::new(&tc);
        let resp = RegisterClientResponse {
            client_id: "abc".into(),
            client_secret: Some("shh".into()),
            client_id_issued_at: Some(1_700_000_000),
            client_secret_expires_at: Some(0),
        };
        cc.write("https://srv.invalid/", &resp).unwrap();
        let back = cc.read("https://srv.invalid/").unwrap().unwrap();
        assert_eq!(back, resp);
    }

    #[test]
    fn client_cache_read_returns_none_when_missing() {
        let dir = TempDir::new().unwrap();
        let tc = TokenCache::with_root(dir.path());
        let cc = ClientCache::new(&tc);
        assert!(cc.read("https://nothing.invalid/").unwrap().is_none());
    }
}
