defmodule LivebookCLI.Server do
  import LivebookCLI.Utils
  @behaviour LivebookCLI.Task

  @external_resource "README.md"

  [_, environment_variables, _] =
    "README.md"
    |> File.read!()
    |> String.split("<!-- Environment variables -->")

  @environment_variables String.trim(environment_variables)

  @impl true
  def usage() do
    """
    Usage: livebook server [options] [open-command]

    An optional open-command can be given as argument. It will open
    up a browser window according these rules:

      * If the open-command is "@home", the browser window will point
        to the home page

      * If the open-command is "@new", the browser window will point
        to a new notebook

      * If the open-command is a URL, the notebook at the given URL
        will be imported

      * If the open-command is a directory, the browser window will point
        to the open page with the directory selected

      * If the open-command is a notebook file, the browser window will point
        to the opened notebook

    The open-command runs after the server is started. If a server is
    already running, the browser window will point to the server
    currently running.

    ## Available options

      --cookie             Sets a cookie for the app distributed node
      --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               Sets a name for the app distributed node
      -p, --port           The port to start the web application on, defaults to 8080

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

    ## Environment variables

    #{@environment_variables}

    ## Examples

    Starts a server:

        livebook server

    Starts a server and opens up a browser at a new notebook:

        livebook server @new

    Starts a server and imports the notebook at the given URL:

        livebook server https://example.com/my-notebook.livemd\
    """
  end

  @impl true
  def call(args) do
    {opts, extra_args} = args_to_options(args)
    config_entries = opts_to_config(opts, [])
    put_config_entries(config_entries)

    case Livebook.Config.port() do
      0 ->
        # When a random port is configured, we can assume no collision
        start_server(extra_args)

      port ->
        base_url = "http://localhost:#{port}"

        case check_endpoint_availability(base_url) do
          :livebook_running ->
            log_info("Livebook already running on #{base_url}")
            open_from_args(base_url, extra_args)

          :taken ->
            raise LivebookCLI.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 ->
            start_server(extra_args)
        end
    end
  end

  defp start_server(extra_args) do
    # We configure the endpoint with `server: true`,
    # so it's gonna start listening
    case Application.ensure_all_started(:livebook) do
      {:ok, _} ->
        open_from_args(LivebookWeb.Endpoint.access_url(), extra_args)
        Process.sleep(:infinity)

      {:error, error} ->
        raise LivebookCLI.Error, "Livebook failed to start with reason: #{inspect(error)}"
    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
    Application.ensure_all_started(:req)
    health_url = set_path(base_url, "/public/health")
    req = Req.new() |> Livebook.Utils.req_attach_defaults()

    case Req.get(req, url: health_url, retry: false) do
      {:ok, %{status: 200, body: %{"application" => "livebook"}}} -> :livebook_running
      {:ok, _other} -> :taken
      {:error, _exception} -> :available
    end
  end

  defp open_from_args(_base_url, []) do
    :ok
  end

  defp open_from_args(base_url, ["@home"]) do
    Livebook.Utils.browser_open(base_url)
  end

  defp open_from_args(base_url, ["@new"]) do
    base_url
    |> set_path("/new")
    |> Livebook.Utils.browser_open()
  end

  defp open_from_args(base_url, ["new"]) do
    log_warning(~s/passing "new" as an argument is deprecated, use "@new" instead/)
    open_from_args(base_url, ["@new"])
  end

  defp open_from_args(base_url, [url_or_file_or_dir]) do
    url = URI.parse(url_or_file_or_dir)
    path = Path.expand(url_or_file_or_dir)

    cond do
      url.scheme in ~w(http https file) ->
        base_url
        |> Livebook.Utils.notebook_import_url(url_or_file_or_dir)
        |> Livebook.Utils.browser_open()

      File.regular?(path) or File.dir?(path) ->
        base_url
        |> Livebook.Utils.notebook_open_url(url_or_file_or_dir)
        |> Livebook.Utils.browser_open()

      true ->
        Livebook.Utils.browser_open(base_url)
    end
  end

  defp open_from_args(_base_url, _extra_args) do
    raise OptionParser.ParseError,
          "Too many arguments entered. Ensure only one argument is used to" <>
            "specify the file path and all other arguments are preceded by the relevant switch"
  end

  @switches [
    cookie: :string,
    ip: :string,
    name: :string,
    port: :integer,
    home: :string
  ]

  @aliases [
    p: :port
  ]

  defp args_to_options(args) do
    OptionParser.parse!(args, strict: @switches, aliases: @aliases)
  end

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

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

  defp opts_to_config([{:ip, ip} | opts], config) do
    ip = Livebook.Config.ip!("--ip", ip)
    host = Livebook.Utils.ip_to_host(ip)

    opts_to_config(opts, [
      {:livebook, LivebookWeb.Endpoint, http: [ip: ip], url: [host: host]} | config
    ])
  end

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

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

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

  defp set_path(url, path) do
    url
    |> URI.parse()
    |> Map.put(:path, path)
    |> URI.to_string()
  end
end
