defmodule PLDSCli.Server do
  @moduledoc false

  # Starts the PLDS server.
  # Most of the code here was copied from Livebook code.
  # Ref: https://github.com/livebook-dev/livebook/blob/2a338b6b83671a54c4d3fce5e58b3ccba0666c37/lib/livebook_cli/server.ex

  def usage do
    """
    Usage: plds server [options]

    Available options:

      -c, --connect        One or more node names to connect.
                           You can specify more nodes by using this option multiple times. 
                           It accepts both short and long names. For short names you can
                           omit the host. If you pass a long name, then PLDS will start
                           with a long name with host "127.0.0.1". You cannot use short
                           and long names together.
      --cookie             Sets a cookie for the app distributed node.
                           This is optional and defaults to `Node.get_cookie()`.
      --ip                 The ip address to start the web application on, defaults to 127.0.0.1
                           Must be a valid IPv4 or IPv6 address.
      --name               Set a name for the app distributed node.
      --open               Open browser window pointing to the application.
      -p, --port           The port to start the web application on. Defaults to 8090.
      --sname              Set a short name for the app distributed node. Defaults to "plds".

    The --help option can be given to print this notice.

    """
  end

  def call(args) do
    opts = args_to_options(args)
    config_entries = opts_to_config(opts, [])
    put_config_entries(config_entries)

    endpoint_config = Application.fetch_env!(:plds, PLDSWeb.Endpoint)

    port = endpoint_config[:http][:port]
    base_url = "http://localhost:#{port}"

    case check_endpoint_availability(base_url) do
      :plds_running ->
        IO.puts("PLDS already running on #{base_url}")
        open_from_options(base_url, opts)

      :taken ->
        print_error(
          "Another application is already running on port #{port}." <>
            " Either ensure this port is free or specify a different port using the --port option"
        )

      :available ->
        case start_server() do
          :ok ->
            open_from_options(PLDSWeb.Endpoint.url(), opts)
            Process.sleep(:infinity)

          :error ->
            print_error("PLDS failed to start")
        end
    end
  end

  # Takes a list of {app, key, value} config entries
  # and overrides the current applications' configuration accordingly.
  # Multiple values for the same key are deeply merged (provided they are keyword lists).
  defp put_config_entries(config_entries) do
    config_entries
    |> Enum.reduce([], fn {app, key, value}, acc ->
      acc = Keyword.put_new_lazy(acc, app, fn -> Application.get_all_env(app) end)
      Config.Reader.merge(acc, [{app, [{key, value}]}])
    end)
    |> Application.put_all_env(persistent: true)
  end

  defp check_endpoint_availability(base_url) do
    url = base_url <> "/health"
    {:ok, _} = Application.ensure_all_started(:inets)

    case :httpc.request(:get, {url, []}, [timeout: 4_000], body_format: :binary) do
      {:ok, {{_, status, _}, _headers, body}} ->
        with 200 <- status,
             {:ok, body} <- Jason.decode(body),
             %{"application" => "plds"} <- body do
          :plds_running
        else
          _ -> :taken
        end

      {:error, _error} ->
        :available
    end
  end

  defp start_server() do
    # We configure the endpoint in prod with `server: true`,
    # so it's gonna start listening
    case Application.ensure_all_started(:plds) do
      {:ok, _} -> :ok
      {:error, _} -> :error
    end
  end

  defp open_from_options(base_url, opts) do
    if opts[:open] do
      browser_open(base_url)
    end
  end

  @switches [
    cookie: :string,
    ip: :string,
    name: :string,
    open: :boolean,
    port: :integer,
    connect: :keep,
    sname: :string
  ]

  @aliases [
    p: :port,
    c: :connect
  ]

  # TODO: add tests to the name/sname and connect validations
  @doc false
  def args_to_options(args) do
    {opts, _} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
    validate_options!(opts)
    opts
  end

  defp validate_options!(opts) do
    if Keyword.has_key?(opts, :name) do
      validate_opts_for_name!(opts)
    end

    if Keyword.has_key?(opts, :sname) do
      validate_opts_for_short_name!(opts)
    end
  end

  defp validate_opts_for_name!(opts) do
    cond do
      Keyword.has_key?(opts, :sname) ->
        raise "the provided --sname and --name options are mutually exclusive, please specify only one of them"

      connecting_to_short_names_with_hosts?(opts) ->
        raise "the provided connections should have long names because you specified the --name option"

      not PLDS.Utils.long_name?(opts[:name]) ->
        raise "the provided --name option should include the full host name"

      true ->
        :ok
    end
  end

  defp validate_opts_for_short_name!(opts) do
    has_full_names? =
      opts
      |> Keyword.take([:connect])
      |> Enum.any?(fn {:connect, name} -> PLDS.Utils.long_name?(name) end)

    if has_full_names? do
      raise "the provided connections should have short names because you specified the --sname option"
    end
  end

  defp connecting_to_short_names_with_hosts?(opts) do
    opts
    |> Keyword.take([:connect])
    |> Enum.any?(fn {:connect, name} -> PLDS.Utils.short_name_with_host?(name) end)
  end

  defp opts_to_config([], config), do: config

  defp opts_to_config([{:port, port} | opts], config) do
    opts_to_config(opts, [{:plds, PLDSWeb.Endpoint, http: [port: port]} | config])
  end

  defp opts_to_config([{:ip, ip} | opts], config) do
    ip = PLDS.Utils.ip!("--ip", ip)
    opts_to_config(opts, [{:plds, PLDSWeb.Endpoint, http: [ip: ip]} | config])
  end

  defp opts_to_config([{:sname, sname} | opts], config) do
    sname = String.to_atom(sname)
    opts_to_config(opts, [{:plds, :node, {:shortnames, sname}} | config])
  end

  defp opts_to_config([{:name, name} | opts], config) do
    name = String.to_atom(name)
    opts_to_config(opts, [{:plds, :node, {:longnames, name}} | config])
  end

  defp opts_to_config([{:cookie, cookie} | opts], config) do
    cookie = String.to_atom(cookie)
    opts_to_config(opts, [{:plds, :cookie, cookie} | config])
  end

  defp opts_to_config([{:connect, name} | opts], config) do
    name = String.to_atom(name)

    previous_nodes_to_connect =
      Enum.find_index(config, &match?({:plds, :nodes_to_connect, _}, &1))

    config =
      case previous_nodes_to_connect do
        nil ->
          [{:plds, :nodes_to_connect, [name]} | config]

        idx ->
          List.update_at(config, idx, fn {:plds, :nodes_to_connect, existing_names} ->
            {:plds, :nodes_to_connect, existing_names ++ [name]}
          end)
      end

    opts_to_config(opts, config)
  end

  defp opts_to_config([_opt | opts], config), do: opts_to_config(opts, config)

  defp browser_open(url) do
    {cmd, args} =
      case :os.type() do
        {:win32, _} -> {"cmd", ["/c", "start", url]}
        {:unix, :darwin} -> {"open", [url]}
        {:unix, _} -> {"xdg-open", [url]}
      end

    System.cmd(cmd, args)
  end

  defp print_error(message) do
    IO.ANSI.format([:red, message]) |> IO.puts()
  end
end
