defmodule Mix.Tasks.Systemd do
  @moduledoc false

  # Directory where `mix systemd.generate` stores output files,
  # e.g. _build/prod/systemd
  @output_dir "systemd"

  # Directory where `mix systemd.init` copies templates in user project
  @template_dir "rel/templates/systemd"

  @app :mix_systemd

  @doc "Generate cfg from mix.exs and app config"
  @spec parse_args(OptionParser.argv()) :: Keyword.t()
  def parse_args(argv) do
    opts = [strict: [version: :string]]
    {overrides, _} = OptionParser.parse!(argv, opts)

    user_config = Application.get_all_env(@app) |> Keyword.merge(overrides)
    mix_config = Mix.Project.config()

    create_config(mix_config, user_config)
  end

  @doc "Generate cfg based on params"
  @spec create_config(Keyword.t(), Keyword.t()) :: Keyword.t()
  def create_config(mix_config, user_config) do
    # Elixir app name, from mix.exs
    app_name =
      cond do
        not is_nil(mix_config[:app]) ->
          mix_config[:app]

        not is_nil(user_config[:app_name]) ->
          user_config[:app_name]

        is_nil(mix_config[:app]) and Mix.Project.umbrella?() ->
          mix_config[:default_release] || Enum.at(mix_config[:releases], 0) |> elem(0)
      end

    # External name, used for files and directories
    ext_name =
      app_name
      |> to_string
      |> String.replace("_", "-")

    # Name of systemd unit
    service_name = ext_name

    # Elixir camel case module name version of snake case app name
    module_name =
      app_name
      |> to_string
      |> String.split("_")
      |> Enum.map_join("", &String.capitalize/1)

    base_dir = user_config[:base_dir] || "/srv"
    build_path = Mix.Project.build_path()

    output_dir =
      user_config[:output_dir] || Path.join([build_path, @output_dir, "/lib/systemd/system"])

    defaults = [
      # Elixir application name
      app_name: app_name,

      # Elixir module name in camel case
      module_name: module_name,

      # Name of release
      release_name: app_name,

      # External name, used for files and directories
      ext_name: ext_name,

      # Name of service
      service_name: service_name,

      # OS user to run app
      app_user: ext_name,
      app_group: ext_name,

      # Name in logs
      syslog_identifier: service_name,

      # Base directory on target system, e.g. /srv
      base_dir: base_dir,

      # Directory for release files on target
      deploy_dir: "#{base_dir}/#{ext_name}",

      # Target systemd version
      # systemd_version: 219, # CentOS 7
      # systemd_version: 229, # Ubuntu 16.04
      # systemd_version: 237, # Ubuntu 18.04
      systemd_version: 235,
      dirs: [
        # :runtime,       # App runtime files which may be deleted between runs, /run/#{ext_name}
        # :configuration, # Config files, Erlang cookie
        # :logs,          # External log file, not journald
        # :cache,         # App cache files which can be deleted
        # :state,         # App state persisted between runs
        # :tmp,           # App temp files
      ],

      # Standard directory locations for under systemd for various purposes.
      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectory=
      #
      # Recent versions of systemd (since 235) will create directories if they
      # don't exist if they are configured in the unit file.
      #
      # For security, modes are tighter than the systemd default of 755.
      # Note that these are strings, not integers, as they are actually octal.
      cache_directory: service_name,
      cache_directory_base: "/var/cache",
      cache_directory_mode: "750",
      configuration_directory: service_name,
      configuration_directory_base: "/etc",
      configuration_directory_mode: "750",
      logs_directory: service_name,
      logs_directory_base: "/var/log",
      logs_directory_mode: "750",
      runtime_directory: service_name,
      runtime_directory_base: "/run",
      runtime_directory_mode: "750",
      # Whether to preserve the runtime dir on app restart
      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#RuntimeDirectoryPreserve=
      # "no" | "yes" | "restart"
      runtime_directory_preserve: "no",
      state_directory: service_name,
      state_directory_base: "/var/lib",
      state_directory_mode: "750",
      tmp_directory: service_name,
      tmp_directory_base: "/var/tmp",
      tmp_directory_mode: "750",

      # Elixir 1.9+ mix releases or Distillery
      # :mix | :distillery
      release_system: :mix,

      # Service start type
      # https://www.freedesktop.org/software/systemd/man/systemd.service.html#Type=
      # :simple | :exec | :notify | :forking
      service_type: :simple,

      # How service is restarted on update
      # :systemctl | :systemd_flag | :touch
      restart_method: :systemctl,

      # Mix build_path
      build_path: build_path,

      # Staging output directory for generated files
      output_dir: output_dir,

      # Directory with templates which override defaults
      template_dir: @template_dir,
      mix_env: Mix.env(),

      # LANG variable
      env_lang: "en_US.utf8",

      # Number of open file descriptors, LimitNOFILE
      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#LimitCPU=
      limit_nofile: 65_535,

      # File mode creation mask, UMask
      # https://www.freedesktop.org/software/systemd/man/systemd.exec.html#UMask=
      umask: "0027",

      # Environment files to read, e.g.
      # env files to read, e.g.
      # The "-" at the beginning means that the file is optional
      env_files: [
        # ["-", :configuration_dir, "/environment"],
        # ["-", :deploy_dir, "/etc/environment"],
      ],

      # Misc env vars to set
      env_vars: [
        #  PORT=8080

        #  Use runtime dir for tmp files
        #  ["RELEASE_TMP=", :runtime_dir]

        #  Distillery
        #  Use runtime dir for tmp and startup log
        #  ["RELEASE_MUTABLE_DIR=", :runtime_dir]
        #  "REPLACE_OS_VARS=true",
      ],

      # Script run by envronment config systemd unit
      runtime_environment_service_script: nil,

      # ExecStartPre commands to run before ExecStart
      # https://www.freedesktop.org/software/systemd/man/systemd.service.html#ExecStartPre=
      exec_start_pre: [],

      # Whether the service shall be restarted when the service process exits, is killed, or times out
      # https://www.freedesktop.org/software/systemd/man/systemd.service.html#Restart=
      restart: "always",

      # Time to sleep before restarting a service
      # https://www.freedesktop.org/software/systemd/man/systemd.service.html#RestartSec=
      restart_sec: nil,

      # Time to wait for start-up
      # https://www.freedesktop.org/software/systemd/man/systemd.service.html#TimeoutStartSec=
      timeout_start_sec: nil,

      # Wrapper script for ExecStart
      exec_start_wrap: nil,

      # Start unit after other systemd unit targets
      unit_after_targets: [],

      # Start runtime environment script after other systemd unit targets
      runtime_environment_unit_after_targets: [],

      # Enable chroot
      chroot: false,
      read_write_paths: [],
      read_only_paths: [],
      inaccessible_paths: [],

      # Enable extra restrictions
      paranoia: false,

      # Config keys which have variable expansion
      expand_keys: [
        :env_files,
        :env_vars,
        :runtime_environment_service_script,
        :exec_start_pre,
        :exec_start_wrap,
        :read_write_paths,
        :read_only_paths,
        :inaccessible_paths
      ],

      # Add your keys here
      expand_keys_extra: []
    ]

    # Override values from user config
    cfg = Keyword.merge(defaults, user_config)

    # Calcualate values from other things
    cfg =
      Keyword.merge(
        [
          releases_dir: cfg[:releases_dir] || Path.join(cfg[:deploy_dir], "releases"),
          scripts_dir: cfg[:scripts_dir] || Path.join(cfg[:deploy_dir], "bin"),
          flags_dir: cfg[:flags_dir] || Path.join(cfg[:deploy_dir], "flags"),
          current_dir: cfg[:current_dir] || Path.join(cfg[:deploy_dir], "current"),
          runtime_dir:
            cfg[:runtime_dir] || Path.join(cfg[:runtime_directory_base], cfg[:runtime_directory]),
          configuration_dir:
            cfg[:configuration_dir] ||
              Path.join(cfg[:configuration_directory_base], cfg[:configuration_directory]),
          logs_dir: cfg[:logs_dir] || Path.join(cfg[:logs_directory_base], cfg[:logs_directory]),
          tmp_dir: cfg[:logs_dir] || Path.join(cfg[:tmp_directory_base], cfg[:tmp_directory]),
          state_dir:
            cfg[:state_dir] || Path.join(cfg[:state_directory_base], cfg[:state_directory]),
          cache_dir:
            cfg[:cache_dir] || Path.join(cfg[:cache_directory_base], cfg[:cache_directory]),

          # Loation of pid file when running as a daemon
          pid_file:
            cfg[:pid_file] ||
              Path.join([cfg[:runtime_directory_base], cfg[:runtime_directory], "#{app_name}.pid"]),

          # Chroot dir
          root_directory: cfg[:root_directory] || Path.join(cfg[:deploy_dir], "current"),
          start_command:
            cfg[:start_command] || start_command(cfg[:service_type], cfg[:release_system]),
          exec_start_wrap: ensure_trailing_space(cfg[:exec_start_wrap]),
          unit_after_targets:
            if cfg[:runtime_environment_service_script] do
              cfg[:unit_after_targets] ++ ["#{cfg[:service_name]}-runtime-environment.service"]
            else
              cfg[:unit_after_targets]
            end
        ],
        cfg
      )

    # Set things based on values computed above
    cfg =
      Keyword.merge(
        [
          working_dir: cfg[:working_dir] || cfg[:current_dir]
        ],
        cfg
      )

    # for {key, value} <- cfg do
    #   Mix.shell.info "cfg: #{key} #{inspect value}"
    # end

    expand_keys(cfg, cfg[:expand_keys] ++ cfg[:expand_keys_extra])
  end

  # Set start comand based on systemd service type and release system
  @doc false
  @spec start_command(atom, atom) :: binary
  def start_command(service_type, release_system)
  # https://hexdocs.pm/mix/Mix.Tasks.Release.html#module-daemon-mode-unix-like
  def start_command(:forking, :mix), do: "daemon"
  def start_command(type, :mix) when type in [:simple, :exec, :notify], do: "start"
  # https://hexdocs.pm/distillery/tooling/cli.html#release-tasks
  def start_command(:forking, :distillery), do: "start"
  def start_command(type, :distillery) when type in [:simple, :exec, :notify], do: "foreground"

  # Make sure that script name has a trailing space if defined
  @doc false
  @spec ensure_trailing_space(nil | binary) :: binary
  def ensure_trailing_space(nil), do: ""

  def ensure_trailing_space(value) do
    if String.ends_with?(value, " "), do: value, else: value <> " "
  end

  # Expand cfg vars in keys
  @doc false
  @spec expand_keys(Keyword.t(), list(atom)) :: Keyword.t()
  def expand_keys(cfg, keys) do
    Enum.reduce(Keyword.take(cfg, keys), cfg, fn {key, value}, acc ->
      Keyword.put(acc, key, expand_value(value, acc))
    end)
  end

  # Expand vars in value or list of values
  @doc false
  @spec expand_value(atom | binary | list, Keyword.t()) :: binary | list(binary)
  def expand_value(values, cfg) when is_list(values) do
    Enum.map(values, &expand_vars(&1, cfg))
  end

  def expand_value(value, cfg), do: expand_vars(value, cfg)

  # Expand references in values
  @doc false
  @spec expand_vars(binary | nil | atom | list, Keyword.t()) :: binary
  def expand_vars(value, _cfg) when is_binary(value), do: value
  def expand_vars(nil, _cfg), do: ""

  def expand_vars(key, cfg) when is_atom(key) do
    case Keyword.fetch(cfg, key) do
      {:ok, value} ->
        expand_vars(value, cfg)

      :error ->
        to_string(key)
    end
  end

  def expand_vars(terms, cfg) when is_list(terms) do
    Enum.map_join(terms, "", &expand_vars(&1, cfg))
  end

  def expand_vars(value, _cfg), do: to_string(value)
end

defmodule Mix.Tasks.Systemd.Init do
  @moduledoc """
  Initialize template files.

  ## Command line options

    * `--template_dir` - target directory

  ## Usage

      # Copy default templates into your project
      mix systemd.init
  """
  @shortdoc "Initialize template files"
  use Mix.Task

  @app :mix_systemd

  @impl Mix.Task
  def run(args) do
    cfg = Mix.Tasks.Systemd.parse_args(args)

    template_dir = cfg[:template_dir]
    app_dir = Application.app_dir(@app, ["priv", "templates"])

    :ok = File.mkdir_p(template_dir)
    {:ok, _files} = File.cp_r(app_dir, template_dir)
  end
end

defmodule Mix.Tasks.Systemd.Generate do
  @moduledoc """
  Create systemd unit files for project.

  ## Usage

      # Create unit files
      MIX_ENV=prod mix systemd.generate
  """
  @shortdoc "Create systemd unit file"
  use Mix.Task

  alias MixSystemd.Templates

  @impl Mix.Task
  def run(args) do
    cfg = Mix.Tasks.Systemd.parse_args(args)

    dest_dir = cfg[:output_dir]
    service_name = cfg[:service_name]

    write_template(cfg, dest_dir, "systemd.service", "#{service_name}.service")

    if cfg[:restart_method] == :systemd_flag do
      write_template(cfg, dest_dir, "restart.service", "#{service_name}-restart.service")
      write_template(cfg, dest_dir, "restart.path", "#{service_name}-restart.path")
    end

    if cfg[:runtime_environment_service] do
      write_template(
        cfg,
        dest_dir,
        "runtime-environment.service",
        "#{service_name}-runtime-environment.service"
      )
    end
  end

  defp write_template(cfg, dest_dir, template, file) do
    # target_file = Path.join(dest_dir, file)
    # Mix.shell.info "Generating #{target_file} from template #{template}"
    Templates.write_template(cfg, dest_dir, template, file)
  end
end
