# frozen_string_literal: true

require "webmock/rspec"

RSpec.describe Dispatch::Adapter::Claude::OAuth do
  let(:tmpdir) { Dir.mktmpdir("oauth_test") }
  let(:store_path) { File.join(tmpdir, "claude_oauth.json") }
  let(:token_store) { Dispatch::Adapter::Claude::TokenStore.new(path: store_path) }

  after { FileUtils.rm_rf(tmpdir) }

  describe ".build_authorize_url" do
    it "builds a valid authorization URL with required parameters" do
      url = described_class.build_authorize_url(
        state: "test_state",
        redirect_uri: "http://127.0.0.1:54545/callback",
        code_challenge: "challenge_abc"
      )
      parsed = URI.parse(url)
      params = URI.decode_www_form(parsed.query).to_h

      expect(parsed.scheme).to eq("https")
      expect(parsed.host).to eq("claude.ai")
      expect(parsed.path).to eq("/oauth/authorize")
      expect(params["client_id"]).to eq(Dispatch::Adapter::Claude::OAuth::CLIENT_ID)
      expect(params["response_type"]).to eq("code")
      expect(params["scope"]).to eq(Dispatch::Adapter::Claude::OAuth::SCOPES)
      expect(params["state"]).to eq("test_state")
      expect(params["redirect_uri"]).to eq("http://127.0.0.1:54545/callback")
      expect(params["code_challenge"]).to eq("challenge_abc")
      expect(params["code_challenge_method"]).to eq("S256")
    end
  end

  describe ".split_code_fragment (via login flow)" do
    it "uses fragment as state override when non-empty" do
      stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
             .with do |req|
               body = JSON.parse(req.body)
               body["code"] == "auth_code" && body["state"] == "state-override"
             end
             .to_return(
               status: 200,
               body: JSON.generate({
                                     "access_token" => "sk-ant-oat01-test",
                                     "refresh_token" => "rt-test",
                                     "expires_in" => 3600
                                   }),
               headers: { "Content-Type" => "application/json" }
             )

      # Simulate the callback server returning code with fragment
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:await_code).and_return(["auth_code#state-override", "original_state"])
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:start)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:stop)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback")

      allow(described_class).to receive(:open_browser)

      described_class.login(token_store: token_store)

      expect(stub).to have_been_requested
    end

    it "keeps explicit state when fragment is empty" do
      allow(SecureRandom).to receive(:hex).and_call_original
      allow(SecureRandom).to receive(:hex).with(16).and_return("original_state")

      stub = stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
             .with do |req|
               body = JSON.parse(req.body)
               body["code"] == "auth_code" && body["state"] == "original_state"
             end
             .to_return(
               status: 200,
               body: JSON.generate({
                                     "access_token" => "sk-ant-oat01-test",
                                     "refresh_token" => "rt-test",
                                     "expires_in" => 3600
                                   }),
               headers: { "Content-Type" => "application/json" }
             )

      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:await_code).and_return(["auth_code#", "original_state"])
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:start)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:stop)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback")

      allow(described_class).to receive(:open_browser)

      described_class.login(token_store: token_store)

      expect(stub).to have_been_requested
    end
  end

  describe ".login" do
    before do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 200,
          body: JSON.generate({
                                "access_token" => "sk-ant-oat01-success",
                                "refresh_token" => "rt-success",
                                "expires_in" => 3600,
                                "email" => "user@example.com"
                              }),
          headers: { "Content-Type" => "application/json" }
        )

      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:await_code).and_return(%w[auth_code_123 state_xyz])
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:start)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:stop)
      allow_any_instance_of(Dispatch::Adapter::Claude::OAuth::CallbackServer)
        .to receive(:callback_url).and_return("http://127.0.0.1:54545/callback")
      allow(described_class).to receive(:open_browser)
    end

    it "persists credentials in the token store on success" do
      described_class.login(token_store: token_store)
      creds = token_store.load
      expect(creds).not_to be_nil
      expect(creds["access_token"]).to eq("sk-ant-oat01-success")
      expect(creds["refresh_token"]).to eq("rt-success")
      expect(creds["email"]).to eq("user@example.com")
    end

    it "stores expires_at_ms as current time + expires_in*1000 - 300_000" do
      before_ms = (Time.now.to_f * 1000).to_i
      described_class.login(token_store: token_store)
      after_ms = (Time.now.to_f * 1000).to_i

      creds = token_store.load
      expected_low  = before_ms + (3600 * 1000) - 300_000
      expected_high = after_ms  + (3600 * 1000) - 300_000
      expect(creds["expires_at_ms"]).to be_between(expected_low, expected_high)
    end

    it "raises AuthenticationError when token endpoint returns 401" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 401,
          body: JSON.generate({ "error" => { "message" => "Invalid code" } }),
          headers: { "Content-Type" => "application/json" }
        )

      expect { described_class.login(token_store: token_store) }
        .to raise_error(Dispatch::Adapter::AuthenticationError)
    end
  end

  describe ".refresh" do
    it "raises AuthenticationError when no stored credentials exist" do
      expect { described_class.refresh(token_store: token_store) }
        .to raise_error(Dispatch::Adapter::AuthenticationError, /No stored credentials/)
    end

    it "raises AuthenticationError when stored credentials have no refresh_token" do
      token_store.save({ "access_token" => "tok", "refresh_token" => nil })
      expect { described_class.refresh(token_store: token_store) }
        .to raise_error(Dispatch::Adapter::AuthenticationError, /No refresh_token/)
    end

    it "refreshes and persists updated credentials" do
      token_store.save({
                         "access_token" => "old-token",
                         "refresh_token" => "rt-old",
                         "expires_at_ms" => 0,
                         "account_id" => "acct-1",
                         "email" => "user@example.com"
                       })

      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .with do |req|
          body = JSON.parse(req.body)
          body["grant_type"] == "refresh_token" && body["refresh_token"] == "rt-old"
        end
        .to_return(
          status: 200,
          body: JSON.generate({
                                "access_token" => "new-access-token",
                                "refresh_token" => "rt-new",
                                "expires_in" => 7200
                              }),
          headers: { "Content-Type" => "application/json" }
        )

      described_class.refresh(token_store: token_store)
      creds = token_store.load

      expect(creds["access_token"]).to eq("new-access-token")
      expect(creds["refresh_token"]).to eq("rt-new")
      expect(creds["email"]).to eq("user@example.com")
    end
  end

  describe ".refresh!" do
    it "returns a credentials hash with new access_token" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .with do |req|
          body = JSON.parse(req.body)
          body["grant_type"] == "refresh_token" && body["refresh_token"] == "old-rt"
        end
        .to_return(
          status: 200,
          body: JSON.generate({
                                "access_token" => "new-access-token",
                                "refresh_token" => "new-rt",
                                "expires_in" => 3600
                              }),
          headers: { "Content-Type" => "application/json" }
        )

      result = described_class.refresh!("old-rt")
      expect(result["access_token"]).to eq("new-access-token")
      expect(result["refresh_token"]).to eq("new-rt")
      expect(result["expires_at_ms"]).to be_a(Integer)
    end

    it "keeps the old refresh_token when the response omits it" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 200,
          body: JSON.generate({
                                "access_token" => "new-access-token",
                                "expires_in" => 3600
                              }),
          headers: { "Content-Type" => "application/json" }
        )

      result = described_class.refresh!("original-rt")
      expect(result["refresh_token"]).to eq("original-rt")
    end

    it "sets expires_at_ms to now + expires_in*1000 - EXPIRY_BUFFER_MS" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 200,
          body: JSON.generate({ "access_token" => "tok", "expires_in" => 3600 }),
          headers: { "Content-Type" => "application/json" }
        )

      before_ms = (Time.now.to_f * 1000).to_i
      result = described_class.refresh!("rt")
      after_ms = (Time.now.to_f * 1000).to_i

      buffer = Dispatch::Adapter::Claude::OAuth::EXPIRY_BUFFER_MS
      expected_low  = before_ms + (3600 * 1000) - buffer
      expected_high = after_ms  + (3600 * 1000) - buffer
      expect(result["expires_at_ms"]).to be_between(expected_low, expected_high)
    end

    it "raises AuthenticationError on 401" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 401,
          body: JSON.generate({ "error" => { "message" => "Bad token" } }),
          headers: { "Content-Type" => "application/json" }
        )

      expect { described_class.refresh!("bad-rt") }
        .to raise_error(Dispatch::Adapter::AuthenticationError, /Refresh failed/)
    end

    it "raises AuthenticationError on 400" do
      stub_request(:post, "https://api.anthropic.com/v1/oauth/token")
        .to_return(
          status: 400,
          body: JSON.generate({ "error" => { "message" => "Invalid grant" } }),
          headers: { "Content-Type" => "application/json" }
        )

      expect { described_class.refresh!("expired-rt") }
        .to raise_error(Dispatch::Adapter::AuthenticationError)
    end
  end
end
