defmodule TeslaMate.ApiTest do
  use TeslaMate.DataCase

  alias TeslaMate.Api
  alias TeslaMate.Auth.Tokens

  import Mock

  def start_api(name, opts \\ []) do
    auth_name = :"auth_#{name}"
    vehicles_name = :"vehicles_#{name}"

    start_auth? = !!Keyword.get(opts, :start_auth, true)

    if start_auth? do
      tokens = Keyword.get(opts, :tokens)
      {:ok, _pid} = start_supervised({AuthMock, name: auth_name, tokens: tokens, pid: self()})
    end

    {:ok, _pid} = start_supervised({VehiclesMock, name: vehicles_name, pid: self()})

    opts = [
      {:name, name},
      {:vehicles, {VehiclesMock, vehicles_name}}
      | if(start_auth?, do: [auth: {AuthMock, auth_name}], else: [])
    ]

    with {:ok, _} <- start_supervised({Api, opts}) do
      :ok
    end
  end

  defp vehicle_mock(pid) do
    {TeslaApi.Vehicle, [],
     [
       list: fn auth ->
         send(pid, {TeslaApi.Vehicle, {:list, auth}})
         {:ok, [%TeslaApi.Vehicle{}]}
       end,
       get: fn auth, id ->
         send(pid, {TeslaApi.Vehicle, {:get, auth, id}})
         {:ok, %TeslaApi.Vehicle{id: id}}
       end,
       get_with_state: fn auth, id ->
         send(pid, {TeslaApi.Vehicle, {:get_with_state, auth, id}})
         {:ok, %TeslaApi.Vehicle{id: id}}
       end
     ]}
  end

  defp auth_mock(pid) do
    {TeslaApi.Auth, [],
     [
       refresh: fn
         %{token: "cannot_be_refreshed", refresh_token: "cannot_be_refreshed"} = auth ->
           send(pid, {TeslaApi.Auth, {:refresh, auth}})
           {:error, %TeslaApi.Error{reason: :induced_error, message: "foo"}}

         auth ->
           send(pid, {TeslaApi.Auth, {:refresh, auth}})
           {:ok, %TeslaApi.Auth{token: "$token", refresh_token: "$token", expires_in: 10_000_000}}
       end
     ]}
  end

  @valid_tokens %Tokens{access: "$access", refresh: "$refresh"}

  setup do
    start_supervised!(TeslaMate.Vault)
    :ok
  end

  describe "sign in" do
    test "starts without tokens", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: nil)

        assert false == Api.signed_in?(name)
        assert {:error, :not_signed_in} = Api.list_vehicles(name)
        assert {:error, :not_signed_in} = Api.get_vehicle(name, 0)
        assert {:error, :not_signed_in} = Api.get_vehicle_with_state(name, 0)

        refute_receive _
      end
    end

    test "starts if tokens are valid", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)

        assert_receive {TeslaApi.Auth,
                        {:refresh, %TeslaApi.Auth{refresh_token: "$refresh", token: "$access"}}}

        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}

        assert true == Api.signed_in?(name)

        refute_receive _
      end
    end

    @tag :capture_log
    test "uses the tokens from the database if the refresh fails", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok =
          start_api(name,
            tokens: %Tokens{access: "cannot_be_refreshed", refresh: "cannot_be_refreshed"}
          )

        assert_receive {TeslaApi.Auth,
                        {:refresh,
                         %TeslaApi.Auth{
                           refresh_token: "cannot_be_refreshed",
                           token: "cannot_be_refreshed"
                         }}}

        assert true == Api.signed_in?(name)

        refute_receive _
      end
    end

    test "allows sign in with API tokens", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: nil)

        assert false == Api.signed_in?(name)

        assert :ok = Api.sign_in(name, @valid_tokens)

        assert_receive {TeslaApi.Auth,
                        {:refresh, %TeslaApi.Auth{refresh_token: "$refresh", token: "$access"}}}

        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}
        assert_receive {VehiclesMock, :restart}
        assert true == Api.signed_in?(name)

        refute_receive _
      end
    end

    test "fails if already signed in", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)

        assert_receive {TeslaApi.Auth, {:refresh, %TeslaApi.Auth{}}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}
        assert true == Api.signed_in?(name)

        assert {:error, :already_signed_in} = Api.sign_in(name, @valid_tokens)

        refute_receive _
      end
    end

    test "fails if api returns error", %{test: name} do
      with_mock TeslaApi.Auth,
        refresh: fn _tokens ->
          {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
        end do
        :ok = start_api(name, start_auth: false)

        assert {:error, %TeslaApi.Error{reason: :unauthorized}} = Api.sign_in(name, @valid_tokens)
      end
    end
  end

  describe "refresh" do
    test "refreshes tokens", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)

        assert_receive {TeslaApi.Auth, {:refresh, %TeslaApi.Auth{}}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}
        assert true == Api.signed_in?(name)

        send(name, :refresh_auth)

        assert_receive {TeslaApi.Auth, {:refresh, %TeslaApi.Auth{}}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}

        refute_receive _
      end
    end
  end

  describe "Vehicle API" do
    test "get_vehicle/1", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)
        assert_receive {TeslaApi.Auth, {:refresh, _}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}

        assert {:ok, %TeslaApi.Vehicle{id: 0}} = Api.get_vehicle(name, 0)
        assert_receive {TeslaApi.Vehicle, {:get, %TeslaApi.Auth{}, 0}}

        refute_receive _
      end
    end

    test "get_vehicle_with_state/1", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)
        assert_receive {TeslaApi.Auth, {:refresh, _}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}

        assert {:ok, %TeslaApi.Vehicle{id: 0}} = Api.get_vehicle_with_state(name, 0)
        assert_receive {TeslaApi.Vehicle, {:get_with_state, %TeslaApi.Auth{}, 0}}

        refute_receive _
      end
    end

    test "list_vehicles/0", %{test: name} do
      with_mocks [auth_mock(self()), vehicle_mock(self())] do
        :ok = start_api(name, tokens: @valid_tokens)
        assert_receive {TeslaApi.Auth, {:refresh, _}}
        assert_receive {AuthMock, {:save, %TeslaApi.Auth{}}}

        assert {:ok, [%TeslaApi.Vehicle{}]} = Api.list_vehicles(name)
        assert_receive {TeslaApi.Vehicle, {:list, %TeslaApi.Auth{}}}

        refute_receive _
      end
    end

    @tag :capture_log
    test "signs out if the API repeatedly returns a 401 response", %{test: name} do
      parent_pid = self()

      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           list: fn _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end,
           get: fn _, _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end,
           get_with_state: fn _, _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end
         ]}

      auth_mock =
        {TeslaApi.Auth, [],
         [
           refresh: fn
             %TeslaApi.Auth{token: "continue_token"} ->
               auth = %TeslaApi.Auth{
                 token: "$token",
                 refresh_token: "$token",
                 expires_in: 10_000_000
               }

               {:ok, auth}

             auth ->
               send(parent_pid, {TeslaApi.Auth, {:refresh, auth}})
               {:error, %TeslaApi.Error{reason: :induced_error, message: "foo"}}
           end
         ]}

      with_mocks [auth_mock, vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        refute_receive _

        for api_fn <- [
              fn -> Api.list_vehicles(name) end,
              fn -> Api.get_vehicle(name, 0) end,
              fn -> Api.get_vehicle_with_state(name, 0) end
            ] do
          # Sign in …
          assert :ok = Api.sign_in(name, %Tokens{access: "continue_token"})

          # retry until the fuse metls and we're signed out
          assert :ok ==
                   Enum.reduce_while(1..10, nil, fn _, _ ->
                     case api_fn.() do
                       {:error, :unauthorized} ->
                         assert_receive {TeslaApi.Auth, {:refresh, _}}
                         {:cont, nil}

                       {:error, :not_signed_in} ->
                         {:halt, :ok}
                     end
                   end)
        end
      end
    end

    test "returns :not_signed_in if Api GenServer is not found", %{test: name} do
      assert {:error, :not_signed_in} = Api.list_vehicles(name)
    end

    test "handles unknown messages gracefully", %{test: name} do
      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           list: fn _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end,
           get: fn _, _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end,
           get_with_state: fn _, _ ->
             {:error, %TeslaApi.Error{reason: :unauthorized, env: %Finch.Response{}}}
           end
         ]}

      with_mocks [auth_mock(self()), vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        true =
          name
          |> Process.whereis()
          |> Process.link()

        send(name, :boom)

        refute_receive _
      end
    end

    @tag :capture_log
    test ":vehicle_not_found", %{test: name} do
      api_error = %TeslaApi.Error{reason: :vehicle_not_found, env: %Finch.Response{}}

      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           get: fn _auth, _id -> {:error, api_error} end,
           get_with_state: fn _auth, _id -> {:error, api_error} end
         ]}

      with_mocks [auth_mock(self()), vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        assert :ok = Api.sign_in(name, @valid_tokens)
        assert {:error, :vehicle_not_found} = Api.get_vehicle(name, 0)
        assert {:error, :vehicle_not_found} = Api.get_vehicle_with_state(name, 0)
      end
    end

    @tag :capture_log
    test ":too_many_requests", %{test: name} do
      api_error = %TeslaApi.Error{
        reason: :too_many_requests,
        message: 300
      }

      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           get: fn _auth, _id -> {:error, api_error} end,
           get_with_state: fn _auth, _id -> {:error, api_error} end
         ]}

      with_mocks [auth_mock(self()), vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        assert :ok = Api.sign_in(name, @valid_tokens)
        assert {:error, :too_many_requests} = Api.get_vehicle(name, 0)
        assert {:error, :too_many_requests} = Api.get_vehicle_with_state(name, 0)
      end
    end

    @tag :capture_log
    test "other error with Env", %{test: name} do
      api_error = %TeslaApi.Error{
        reason: :unknown,
        message: "",
        env: %Finch.Response{status: 503, body: ""}
      }

      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           list: fn _auth -> {:error, api_error} end,
           get: fn _auth, _id -> {:error, api_error} end,
           get_with_state: fn _auth, _id -> {:error, api_error} end
         ]}

      with_mocks [auth_mock(self()), vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        assert :ok = Api.sign_in(name, @valid_tokens)

        assert {:error, :unknown} = Api.list_vehicles(name)
        assert {:error, :unknown} = Api.get_vehicle(name, 0)
        assert {:error, :unknown} = Api.get_vehicle_with_state(name, 0)
      end
    end

    @tag :capture_log
    test "other error witnout Env", %{test: name} do
      api_error = %TeslaApi.Error{reason: :closed, message: "foo"}

      vehicle_mock =
        {TeslaApi.Vehicle, [],
         [
           list: fn _auth -> {:error, api_error} end,
           get: fn _auth, _id -> {:error, api_error} end,
           get_with_state: fn _auth, _id -> {:error, api_error} end
         ]}

      with_mocks [auth_mock(self()), vehicle_mock] do
        :ok = start_api(name, start_auth: false)

        assert :ok = Api.sign_in(name, @valid_tokens)
        assert {:error, :closed} = Api.list_vehicles(name)
        assert {:error, :closed} = Api.get_vehicle(name, 0)
        assert {:error, :closed} = Api.get_vehicle_with_state(name, 0)
      end
    end
  end
end
