# frozen_string_literal: true

require_relative "oauth/callback_server"

module Dispatch
  module Adapter
    class Claude < Base
      module OAuth
        CLIENT_ID     = Base64.decode64("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl")
        AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
        TOKEN_URL     = "https://api.anthropic.com/v1/oauth/token"
        # Full Claude Code scope set (matches recent Claude Code releases as
        # noted in oh-my-pi's CHANGELOG). Anthropic silently drops scopes
        # that don't apply to a given account type (e.g. `org:create_api_key`
        # is dropped for Pro/Max accounts), so this set is safe for all users
        # and produces a token that is indistinguishable from a real Claude
        # Code session in the user's authorized-apps list.
        SCOPES        = "org:create_api_key user:profile user:inference " \
                        "user:sessions:claude_code user:mcp_servers user:file_upload"
        CALLBACK_PORT = 54_545
        CALLBACK_PATH = "/callback"
        EXPIRY_BUFFER_MS = 5 * 60 * 1000

        module_function

        # Perform the full PKCE OAuth login flow.
        # Opens a browser (best effort), waits for the callback, exchanges the
        # code for tokens, and persists them via the given token_store.
        #
        # @param token_store [TokenStore]
        # @param port [Integer] local callback port (default 54545)
        # @param timeout [Integer] seconds to wait for the browser callback
        # @return [Hash] the persisted credential hash
        def login(token_store:, port: CALLBACK_PORT, timeout: 300)
          pkce  = PKCE.generate
          state = SecureRandom.hex(16)

          server = CallbackServer.new(port: port, timeout: timeout)
          server.start
          redirect_uri = server.callback_url

          auth_url = build_authorize_url(
            state: state,
            redirect_uri: redirect_uri,
            code_challenge: pkce[:challenge]
          )

          warn "\n=== Anthropic Claude OAuth Login ==="
          warn "Opening browser for authentication..."
          warn "If your browser did not open, paste this URL into it:"
          warn auth_url
          warn ""

          open_browser(auth_url)

          raw_code, _returned_state = server.await_code
          server.stop

          # Split code on '#' — if the fragment is non-empty it overrides state
          # for the token exchange (mirrors oh-my-pi behaviour).
          exchange_code, exchange_state = split_code_fragment(raw_code, state)

          creds = exchange_code_for_tokens(
            code: exchange_code,
            state: exchange_state,
            redirect_uri: redirect_uri,
            code_verifier: pkce[:verifier]
          )

          token_store.save(creds)
          creds
        end

        # Refresh an existing OAuth access token using the refresh token.
        #
        # @param token_store [TokenStore]
        # @return [Hash] the updated credential hash
        def refresh(token_store:)
          creds = token_store.load
          raise AuthenticationError.new("No stored credentials to refresh.", provider: ClaudeErrors::PROVIDER) unless creds

          refresh_token = creds["refresh_token"]
          raise AuthenticationError.new("No refresh_token in stored credentials.", provider: ClaudeErrors::PROVIDER) unless refresh_token

          body = JSON.generate({
                                 grant_type: "refresh_token",
                                 client_id: CLIENT_ID,
                                 refresh_token: refresh_token
                               })

          data = post_token(body)
          updated = build_creds_hash(data, creds)
          token_store.save(updated)
          updated
        end

        # Build the authorise URL for the PKCE flow.
        def build_authorize_url(state:, redirect_uri:, code_challenge:)
          params = URI.encode_www_form(
            code: "true",
            client_id: CLIENT_ID,
            response_type: "code",
            redirect_uri: redirect_uri,
            scope: SCOPES,
            code_challenge: code_challenge,
            code_challenge_method: "S256",
            state: state
          )
          "#{AUTHORIZE_URL}?#{params}"
        end

        # Exchange a refresh token for a new access token.
        # The refresh token is rotated only if the response includes a new one.
        #
        # @param refresh_token [String] the current refresh token
        # @return [Hash] new credentials hash (access_token, refresh_token, expires_at_ms)
        def refresh!(refresh_token)
          body = JSON.generate(
            grant_type: "refresh_token",
            client_id: CLIENT_ID,
            refresh_token: refresh_token
          )

          uri = URI(TOKEN_URL)
          req = Net::HTTP::Post.new(
            uri,
            "Content-Type" => "application/json",
            "Accept" => "application/json"
          )
          req.body = body

          resp = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }

          unless resp.is_a?(Net::HTTPSuccess)
            raise AuthenticationError.new(
              "Refresh failed: #{resp.body}",
              status_code: resp.code.to_i,
              provider: ClaudeErrors::PROVIDER
            )
          end

          data = JSON.parse(resp.body)
          {
            "access_token" => data["access_token"],
            "refresh_token" => data["refresh_token"] || refresh_token,
            "expires_at_ms" => (Time.now.to_f * 1000).to_i + (data["expires_in"].to_i * 1000) - EXPIRY_BUFFER_MS
          }
        end

        # Split `raw_code` on '#'. If the fragment part is non-empty it
        # overrides the state for the token exchange.
        def split_code_fragment(raw_code, state)
          code_part, fragment = raw_code.split("#", 2)
          exchange_state = fragment && !fragment.empty? ? fragment : state
          [code_part, exchange_state]
        end

        # POST the authorisation code to the token endpoint.
        def exchange_code_for_tokens(code:, state:, redirect_uri:, code_verifier:)
          body = JSON.generate({
                                 grant_type: "authorization_code",
                                 client_id: CLIENT_ID,
                                 code: code,
                                 state: state,
                                 redirect_uri: redirect_uri,
                                 code_verifier: code_verifier
                               })

          data = post_token(body)
          build_creds_hash(data)
        end

        # HTTP POST to the token endpoint; returns the parsed JSON body.
        def post_token(json_body)
          uri = URI(TOKEN_URL)
          req = Net::HTTP::Post.new(uri)
          req["Content-Type"] = "application/json"
          req["Accept"] = "application/json"
          req.body = json_body

          http = Net::HTTP.new(uri.host, uri.port)
          http.use_ssl = true
          http.open_timeout = 30
          http.read_timeout = 30

          response = http.start { |h| h.request(req) }
          ClaudeErrors.handle_response!(response)

          JSON.parse(response.body)
        rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
          raise ConnectionError.new("OAuth token exchange failed: #{e.message}", provider: ClaudeErrors::PROVIDER)
        end

        # Build the credential hash to persist.
        def build_creds_hash(data, existing = {})
          expires_in_ms = (data["expires_in"].to_i * 1000) - 300_000
          {
            "access_token" => data["access_token"],
            "refresh_token" => data["refresh_token"] || existing["refresh_token"],
            "expires_at_ms" => (Time.now.to_f * 1000).to_i + expires_in_ms,
            "account_id" => data["account_id"] || existing["account_id"],
            "email" => data["email"] || existing["email"]
          }
        end

        # Best-effort browser opener — silent on failure.
        def open_browser(url)
          case RUBY_PLATFORM
          when /linux/i
            system("xdg-open", url, out: File::NULL, err: File::NULL)
          when /darwin/i
            system("open", url, out: File::NULL, err: File::NULL)
          when /mswin|mingw|cygwin/i
            system("start", url, out: File::NULL, err: File::NULL)
          end
        rescue StandardError
          # Ignore all errors — the URL is already printed to stderr
        end
      end
    end
  end
end
