defmodule LivebookCLI.Deploy do
  import LivebookCLI.Utils
  alias Livebook.Teams

  @behaviour LivebookCLI.Task

  @org_token_prefix Teams.Constants.org_token_prefix()
  @teams_key_prefix Teams.Constants.teams_key_prefix()

  @impl true
  def usage() do
    """
    Usage: livebook deploy [options] filename [filename...]

    ## Available options

      --org-token             Token from your Livebook Teams organization
      --teams-key             Teams key from your Teams workspace
      --deployment-group-id   The ID of the deployment group you want to deploy to

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

    ## Examples

    Deploys a single notebook:

        livebook deploy --org-token="lb_ot_..." --teams-key="lb_tk_..." --deployment-group-id=123 path/to/app1.livemd

    Deploys multiple notebooks:

        livebook deploy --org-token="lb_ot_..." --teams-key="lb_tk_..." --deployment-group-id=123 path/to/*.livemd\
    """
  end

  @switches [
    org_token: :string,
    teams_key: :string,
    deployment_group_id: :integer
  ]

  @impl true
  def call(args) do
    Application.put_env(:livebook, :persist_storage, false)
    {:ok, _} = Application.ensure_all_started(:livebook)
    config = config_from_args(args)
    ensure_config!(config)

    team = authenticate_cli!(config)
    deploy_to_teams(team, config)
  end

  defp config_from_args(args) do
    {opts, paths} = OptionParser.parse!(args, strict: @switches)

    %{
      paths: paths,
      session_token: opts[:org_token],
      teams_key: opts[:teams_key],
      deployment_group_id: opts[:deployment_group_id]
    }
  end

  defp ensure_config!(config) do
    log_debug("Validating config from options...")

    errors =
      Enum.reduce(config, %{}, fn
        {key, value}, acc when value in ["", nil] ->
          add_error(acc, normalize_key(key), "can't be blank")

        {:session_token, value}, acc ->
          if not String.starts_with?(value, @org_token_prefix) do
            add_error(
              acc,
              normalize_key(:session_token),
              "must be a Livebook Teams organization token"
            )
          else
            acc
          end

        {:teams_key, value}, acc ->
          if not String.starts_with?(value, @teams_key_prefix) do
            add_error(acc, normalize_key(:teams_key), "must be a Livebook Teams key")
          else
            acc
          end

        {:paths, values}, acc ->
          Enum.reduce_while(values, acc, &validate_path/2)

        _otherwise, acc ->
          acc
      end)

    if Map.keys(errors) == [] do
      :ok
    else
      raise LivebookCLI.Error, """
      You configuration is invalid, make sure you are using the correct options for this task.

      #{format_errors(errors, " * ")}\
      """
    end
  end

  defp validate_path(value, acc) do
    cond do
      not File.exists?(value) ->
        {:halt, add_error(acc, normalize_key(:paths), "must be a valid path")}

      File.dir?(value) ->
        {:halt, add_error(acc, normalize_key(:paths), "must be a file path")}

      true ->
        {:cont, acc}
    end
  end

  defp authenticate_cli!(config) do
    log_debug("Authenticating CLI...")

    case Teams.fetch_cli_session(config) do
      {:ok, team} ->
        team

      {:error, error} ->
        raise LivebookCLI.Error, error

      {:transport_error, error} ->
        raise LivebookCLI.Error, error
    end
  end

  defp deploy_to_teams(team, config) do
    if length(config.paths) == 1 do
      log_debug("Found 1 notebook")
    else
      log_debug("Found #{length(config.paths)} notebooks")
    end

    log_info("Deploying notebooks:")

    deploy_results =
      for path <- config.paths do
        log_info(" * Preparing to deploy notebook #{Path.basename(path)}")

        absolute_path = Path.expand(path)
        files_dir = Livebook.FileSystem.File.local(absolute_path)

        with {:ok, content} <- File.read(path),
             {:ok, app_deployment} <- prepare_app_deployment(path, content, files_dir) do
          case Livebook.Teams.deploy_app_from_cli(
                 team,
                 app_deployment,
                 config.deployment_group_id
               ) do
            {:ok, url} ->
              log_info([:green, "  * #{app_deployment.title} deployed successfully. (#{url})"])

            {:error, errors} ->
              log_error("  * #{app_deployment.title} failed to deploy.")

              error_message =
                errors
                |> normalize_errors
                |> format_errors("    * ")

              log_error(error_message)

              :error

            {:transport_error, reason} ->
              log_error(
                "  * #{app_deployment.title} failed to deploy. Transport error: #{reason}"
              )

              :error
          end
        end
      end

    if Enum.any?(deploy_results, fn result -> result != :ok end) do
      raise LivebookCLI.Error, "Some app deployments failed."
    else
      :ok
    end
  end

  defp prepare_app_deployment(path, content, files_dir) do
    case Livebook.Teams.AppDeployment.new(content, files_dir) do
      {:ok, app_deployment} ->
        {:ok, app_deployment}

      {:warning, warnings} ->
        error_message = """
          * Deployment for notebook #{Path.basename(path)} failed because the notebook has some warnings:
          #{format_list(warnings, " * ")}
        """

        log_error(error_message)

        :error

      {:error, reason} ->
        log_error("  * Failed to handle I/O operations: #{reason}")

        :error
    end
  end

  defp add_error(errors, key, message) do
    Map.update(errors, key, [message], &[message | &1])
  end

  def normalize_errors(%{} = errors) do
    for {key, values} <- errors, into: %{} do
      {normalize_key(key), values}
    end
  end

  defp normalize_key(key) when is_atom(key), do: to_string(key) |> normalize_key()
  defp normalize_key("session_token"), do: "Token"
  defp normalize_key("teams_key"), do: "Teams key"
  defp normalize_key("deployment_group_id"), do: "Deployment group id"

  defp normalize_key("paths"), do: "File paths"

  defp format_errors(errors, prefix) do
    errors
    |> Enum.map(fn {key, values} ->
      values |> Enum.map(&"#{prefix}#{key} #{&1}") |> Enum.join("\n")
    end)
    |> Enum.join("\n")
  end

  defp format_list(errors, prefix) do
    errors |> Enum.map(&"#{prefix}#{&1}") |> Enum.join("\n")
  end
end
