defmodule Atex.OAuth do
  @moduledoc """
  OAuth 2.0 implementation for AT Protocol authentication.

  This module provides utilities for implementing OAuth flows compliant with the
  AT Protocol specification. It includes support for:

  - Pushed Authorization Requests (PAR)
  - DPoP (Demonstration of Proof of Possession) tokens
  - JWT client assertions
  - PKCE (Proof Key for Code Exchange)
  - Token refresh
  - Handle to PDS resolution

  ## Configuration

  See `Atex.Config.OAuth` module for configuration documentation.

  ## Usage Example

      iex> pds = "https://bsky.social"
      iex> login_hint = "example.com"
      iex> {:ok, authz_server} = Atex.OAuth.get_authorization_server(pds)
      iex> {:ok, authz_metadata} = Atex.OAuth.get_authorization_server_metadata(authz_server)
      iex> state = Atex.OAuth.create_nonce()
      iex> code_verifier = Atex.OAuth.create_nonce()
      iex> {:ok, auth_url} = Atex.OAuth.create_authorization_url(
        authz_metadata,
        state,
        code_verifier,
        login_hint
      )
  """

  @type authorization_metadata() :: %{
          issuer: String.t(),
          par_endpoint: String.t(),
          token_endpoint: String.t(),
          authorization_endpoint: String.t()
        }

  @type tokens() :: %{
          access_token: String.t(),
          refresh_token: String.t(),
          did: String.t(),
          expires_at: NaiveDateTime.t()
        }

  alias Atex.Config.OAuth, as: Config

  @doc """
  Get a map cnotaining the client metadata information needed for an
  authorization server to validate this client.
  """
  @spec create_client_metadata() :: map()
  def create_client_metadata() do
    key = Config.get_key()
    {_, jwk} = key |> JOSE.JWK.to_public_map()
    jwk = Map.merge(jwk, %{use: "sig", kid: key.fields["kid"]})

    %{
      client_id: Config.client_id(),
      redirect_uris: [Config.redirect_uri() | Config.extra_redirect_uris()],
      application_type: "web",
      grant_types: ["authorization_code", "refresh_token"],
      scope: Config.scopes(),
      response_type: ["code"],
      token_endpoint_auth_method: "private_key_jwt",
      token_endpoint_auth_signing_alg: "ES256",
      dpop_bound_access_tokens: true,
      jwks: %{keys: [jwk]}
    }
  end

  @doc """
  Retrieves the configured JWT private key for signing client assertions.

  Loads the private key from configuration, decodes the base64-encoded DER data,
  and creates a JOSE JWK structure with the key ID field set.

  ## Returns

  A `JOSE.JWK` struct containing the private key and key identifier.

  ## Raises

  * `Application.Env.Error` if the private_key or key_id configuration is missing

  ## Examples

      key = OAuth.get_key()
      key = OAuth.get_key()
  """
  @spec get_key() :: JOSE.JWK.t()
  def get_key(), do: Config.get_key()

  @doc false
  @spec random_b64(integer()) :: String.t()
  def random_b64(length) do
    :crypto.strong_rand_bytes(length)
    |> Base.url_encode64(padding: false)
  end

  @doc false
  @spec create_nonce() :: String.t()
  def create_nonce(), do: random_b64(32)

  @doc """
  Create an OAuth authorization URL for a PDS.

  Submits a PAR request to the authorization server and constructs the
  authorization URL with the returned request URI. Supports PKCE, DPoP, and
  client assertions as required by the AT Protocol.

  ## Parameters

    - `authz_metadata` - Authorization server metadata containing endpoints, fetched from `get_authorization_server_metadata/1`
    - `state` - Random token for session validation
    - `code_verifier` - PKCE code verifier
    - `login_hint` - User identifier (handle or DID) for pre-filled login

  ## Returns

    - `{:ok, authorization_url}` - Successfully created authorization URL
    - `{:ok, :invalid_par_response}` - Server respondend incorrectly to the request
    - `{:error, reason}` - Error creating authorization URL
  """
  @spec create_authorization_url(
          authorization_metadata(),
          String.t(),
          String.t(),
          String.t()
        ) :: {:ok, String.t()} | {:error, any()}
  def create_authorization_url(
        authz_metadata,
        state,
        code_verifier,
        login_hint
      ) do
    code_challenge = :crypto.hash(:sha256, code_verifier) |> Base.url_encode64(padding: false)
    key = get_key()

    client_assertion =
      create_client_assertion(key, Config.client_id(), authz_metadata.issuer)

    body =
      %{
        response_type: "code",
        client_id: Config.client_id(),
        redirect_uri: Config.redirect_uri(),
        state: state,
        code_challenge_method: "S256",
        code_challenge: code_challenge,
        scope: Config.scopes(),
        client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        client_assertion: client_assertion,
        login_hint: login_hint
      }

    case Req.post(authz_metadata.par_endpoint, form: body) do
      {:ok, %{body: %{"request_uri" => request_uri}}} ->
        query =
          %{client_id: Config.client_id(), request_uri: request_uri}
          |> URI.encode_query()

        {:ok, "#{authz_metadata.authorization_endpoint}?#{query}"}

      {:ok, _} ->
        {:error, :invalid_par_response}

      err ->
        err
    end
  end

  @doc """
  Exchange an OAuth authorization code for a set of access and refresh tokens.

  Validates the authorization code by submitting it to the token endpoint along with
  the PKCE code verifier and client assertion. Returns access tokens for making authenticated
  requests to the relevant user's PDS.

  ## Parameters

    - `authz_metadata` - Authorization server metadata containing token endpoint
    - `dpop_key` - JWK for DPoP token generation
    - `code` - Authorization code from OAuth callback
    - `code_verifier` - PKCE code verifier from authorization flow

  ## Returns

    - `{:ok, tokens, nonce}` - Successfully obtained tokens with returned DPoP nonce
    - `{:error, reason}` - Error exchanging code for tokens
  """
  @spec validate_authorization_code(
          authorization_metadata(),
          JOSE.JWK.t(),
          String.t(),
          String.t()
        ) :: {:ok, tokens(), String.t()} | {:error, any()}
  def validate_authorization_code(
        authz_metadata,
        dpop_key,
        code,
        code_verifier
      ) do
    key = get_key()

    client_assertion =
      create_client_assertion(key, Config.client_id(), authz_metadata.issuer)

    body =
      %{
        grant_type: "authorization_code",
        client_id: Config.client_id(),
        redirect_uri: Config.redirect_uri(),
        code: code,
        code_verifier: code_verifier,
        client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        client_assertion: client_assertion
      }

    Req.new(method: :post, url: authz_metadata.token_endpoint, form: body)
    |> send_oauth_dpop_request(dpop_key)
    |> case do
      {:ok,
       %{
         "access_token" => access_token,
         "refresh_token" => refresh_token,
         "expires_in" => expires_in,
         "sub" => did
       }, nonce} ->
        expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)

        {:ok,
         %{
           access_token: access_token,
           refresh_token: refresh_token,
           did: did,
           expires_at: expires_at
         }, nonce}

      err ->
        err
    end
  end

  def refresh_token(refresh_token, dpop_key, issuer, token_endpoint) do
    key = get_key()

    client_assertion =
      create_client_assertion(key, Config.client_id(), issuer)

    body = %{
      grant_type: "refresh_token",
      refresh_token: refresh_token,
      client_id: Config.client_id(),
      client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
      client_assertion: client_assertion
    }

    Req.new(method: :post, url: token_endpoint, form: body)
    |> send_oauth_dpop_request(dpop_key)
    |> case do
      {:ok,
       %{
         "access_token" => access_token,
         "refresh_token" => refresh_token,
         "expires_in" => expires_in,
         "sub" => did
       }, nonce} ->
        expires_at = NaiveDateTime.utc_now() |> NaiveDateTime.add(expires_in, :second)

        {:ok,
         %{
           access_token: access_token,
           refresh_token: refresh_token,
           did: did,
           expires_at: expires_at
         }, nonce}

      err ->
        err
    end
  end

  @doc """
  Fetch the authorization server for a given Personal Data Server (PDS).

  Makes a request to the PDS's `.well-known/oauth-protected-resource` endpoint
  to discover the associated authorization server that should be used for the
  OAuth flow.

  ## Parameters

    - `pds_host` - Base URL of the PDS (e.g., "https://bsky.social")

  ## Returns

    - `{:ok, authorization_server}` - Successfully discovered authorization
      server URL
    - `{:error, :invalid_metadata}` - Server returned invalid metadata
    - `{:error, reason}` - Error discovering authorization server
  """
  @spec get_authorization_server(String.t()) :: {:ok, String.t()} | {:error, any()}
  def get_authorization_server(pds_host) do
    "#{pds_host}/.well-known/oauth-protected-resource"
    |> Req.get()
    |> case do
      # TODO: what to do when multiple authorization servers?
      {:ok, %{body: %{"authorization_servers" => [authz_server | _]}}} -> {:ok, authz_server}
      {:ok, _} -> {:error, :invalid_metadata}
      err -> err
    end
  end

  @doc """
  Fetch the metadata for an OAuth authorization server.

  Retrieves the metadata from the authorization server's
  `.well-known/oauth-authorization-server` endpoint, providing endpoint URLs
  required for the OAuth flow.

  ## Parameters

    - `issuer` - Authorization server issuer URL

  ## Returns

    - `{:ok, metadata}` - Successfully retrieved authorization server metadata
    - `{:error, :invalid_metadata}` - Server returned invalid metadata
    - `{:error, :invalid_issuer}` - Issuer mismatch in metadata
    - `{:error, any()}` - Other error fetching metadata
  """
  @spec get_authorization_server_metadata(String.t()) ::
          {:ok, authorization_metadata()} | {:error, any()}
  def get_authorization_server_metadata(issuer) do
    "#{issuer}/.well-known/oauth-authorization-server"
    |> Req.get()
    |> case do
      {:ok,
       %{
         body: %{
           "issuer" => metadata_issuer,
           "pushed_authorization_request_endpoint" => par_endpoint,
           "token_endpoint" => token_endpoint,
           "authorization_endpoint" => authorization_endpoint
         }
       }} ->
        if issuer != metadata_issuer do
          {:error, :invaild_issuer}
        else
          {:ok,
           %{
             issuer: metadata_issuer,
             par_endpoint: par_endpoint,
             token_endpoint: token_endpoint,
             authorization_endpoint: authorization_endpoint
           }}
        end

      {:ok, _} ->
        {:error, :invalid_metadata}

      err ->
        err
    end
  end

  @spec send_oauth_dpop_request(Req.Request.t(), JOSE.JWK.t(), String.t() | nil) ::
          {:ok, map(), String.t()} | {:error, any(), String.t()}
  def send_oauth_dpop_request(request, dpop_key, nonce \\ nil) do
    dpop_token = create_dpop_token(dpop_key, request, nonce)

    request
    |> Req.Request.put_header("dpop", dpop_token)
    |> Req.request()
    |> case do
      {:ok, resp} ->
        dpop_nonce =
          case resp.headers["dpop-nonce"] do
            [new_nonce | _] -> new_nonce
            _ -> nonce
          end

        cond do
          resp.status == 200 ->
            {:ok, resp.body, dpop_nonce}

          resp.body["error"] === "use_dpop_nonce" ->
            dpop_token = create_dpop_token(dpop_key, request, dpop_nonce)

            request
            |> Req.Request.put_header("dpop", dpop_token)
            |> Req.request()
            |> case do
              {:ok, %{status: 200, body: body}} ->
                {:ok, body, dpop_nonce}

              {:ok, %{body: %{"error" => error, "error_description" => error_description}}} ->
                {:error, {:oauth_error, error, error_description}, dpop_nonce}

              {:ok, _} ->
                {:error, :unexpected_response, dpop_nonce}

              {:error, err} ->
                {:error, err, dpop_nonce}
            end

          true ->
            {:error, {:oauth_error, resp.body["error"], resp.body["error_description"]},
             dpop_nonce}
        end

      {:error, err} ->
        {:error, err, nonce}
    end
  end

  @spec request_protected_dpop_resource(
          Req.Request.t(),
          String.t(),
          String.t(),
          JOSE.JWK.t(),
          String.t() | nil
        ) :: {:ok, Req.Response.t(), String.t() | nil} | {:error, any()}
  def request_protected_dpop_resource(request, issuer, access_token, dpop_key, nonce \\ nil) do
    access_token_hash = :crypto.hash(:sha256, access_token) |> Base.url_encode64(padding: false)
    # access_token_hash = Base.url_encode64(access_token, padding: false)

    dpop_token =
      create_dpop_token(dpop_key, request, nonce, %{iss: issuer, ath: access_token_hash})

    request
    |> Req.Request.put_header("dpop", dpop_token)
    |> Req.request()
    |> case do
      {:ok, resp} ->
        dpop_nonce =
          case resp.headers["dpop-nonce"] do
            [new_nonce | _] -> new_nonce
            _ -> nonce
          end

        www_authenticate = Req.Response.get_header(resp, "www-authenticate")

        www_dpop_problem =
          www_authenticate != [] && String.starts_with?(Enum.at(www_authenticate, 0), "DPoP")

        if resp.status != 401 || !www_dpop_problem do
          {:ok, resp, dpop_nonce}
        else
          dpop_token =
            create_dpop_token(dpop_key, request, dpop_nonce, %{
              iss: issuer,
              ath: access_token_hash
            })

          request
          |> Req.Request.put_header("dpop", dpop_token)
          |> Req.request()
          |> case do
            {:ok, resp} ->
              dpop_nonce =
                case resp.headers["dpop-nonce"] do
                  [new_nonce | _] -> new_nonce
                  _ -> dpop_nonce
                end

              {:ok, resp, dpop_nonce}

            err ->
              err
          end
        end

      err ->
        err
    end
  end

  @spec create_client_assertion(JOSE.JWK.t(), String.t(), String.t()) :: String.t()
  def create_client_assertion(jwk, client_id, issuer) do
    iat = System.os_time(:second)
    jti = random_b64(20)
    jws = %{"alg" => "ES256", "kid" => jwk.fields["kid"]}

    jwt = %{
      iss: client_id,
      sub: client_id,
      aud: issuer,
      jti: jti,
      iat: iat,
      exp: iat + 60
    }

    JOSE.JWT.sign(jwk, jws, jwt)
    |> JOSE.JWS.compact()
    |> elem(1)
  end

  @spec create_dpop_token(JOSE.JWK.t(), Req.Request.t(), any(), map()) :: String.t()
  def create_dpop_token(jwk, request, nonce \\ nil, attrs \\ %{}) do
    iat = System.os_time(:second)
    jti = random_b64(20)
    {_, public_jwk} = JOSE.JWK.to_public_map(jwk)
    jws = %{"alg" => "ES256", "typ" => "dpop+jwt", "jwk" => public_jwk}
    [request_url | _] = request.url |> to_string() |> String.split("?")

    jwt =
      Map.merge(attrs, %{
        jti: jti,
        htm: atom_to_upcase_string(request.method),
        htu: request_url,
        iat: iat,
        nonce: nonce
      })

    JOSE.JWT.sign(jwk, jws, jwt)
    |> JOSE.JWS.compact()
    |> elem(1)
  end

  @doc false
  @spec atom_to_upcase_string(atom()) :: String.t()
  def atom_to_upcase_string(atom) do
    atom |> to_string() |> String.upcase()
  end
end
