lib/mix/tasks/coh.install.ex

defmodule Mix.Tasks.Coh.Install do
  @moduledoc """
  Configure the Coherence User Model for your Phoenix application. Coherence
  is composed of a number of modules that can be enabled with this installer.

  This installer will normally do the following unless given an option not to do so:

  * Append the :coherence configuration to your `config/config.exs` file.
  * Generate appropriate migration files.
  * Generate appropriate view files.
  * Generate appropriate template files.
  * Generate a `web/coherence_web.ex` file.
  * Generate a `web/coherence_messages.ex` file.
  * Generate a `web/models/user.ex` file if one does not already exist.

  ## Install Examples

      # Install with only the `authenticatable` option
      mix coh.install

      # Install all the options except `confirmable` and `invitable`
      mix coh.install --full

      # Install all the options except `invitable`
      mix coh.install --full-confirmable

      # Install all the options except `confirmable`
      mix coh.install --full-invitable

      # Install the `full` options except `lockable` and `trackable`
      mix coh.install --full --no-lockable --no-trackable

  ## Reinstall Examples

      # Reinstall with defaults (--silent --no-migrations --no-config --confirm-once)
      mix coh.install --reinstall

      # Confirm to overwrite files, show instructions, and generate migrations
      mix coh.install --reinstall --no-confirm-once --with-migrations

  ## Option list

  A Coherence configuration will be appended to your `config/config.exs` file unless
  the `--no-config` option is given.

  A `--model="SomeModule tablename"` option can be given to override the default User module.

  A `--repo=CustomRepo` option can be given to override the default Repo module

  A `--router=CustomRouter` option can be given to override the default Router module

  A `--web-path="lib/my_project/web"` option can be given to specify the web path

  A `--default` option will include only `authenticatable`

  A `--full` option will include options `authenticatable`, `recoverable`, `lockable`, `trackable`, `unlockable_with_token`, `registerable`

  A `--full-confirmable` option will include the `--full` options in addition to the `--confirmable` option

  A `--full-invitable` option will include the `--full` options in addition to the `--invitable` option

  An `--authenticatable` option provides authentication support to your User model.

  A `--recoverable` option provides the ability to request a password reset email.

  A `--lockable` option provides login locking after too many failed login attempts.

  An `--unlockable-with-token` option provides the ability to request an unlock email.

  A `--trackable` option provides login count, current login timestamp, current login ip, last login timestamp, last login ip in your User model.

  A `--trackable-table` option provides `trackable` fields in the `trackables` table.

  A `--confirmable` option provides support for confirmation email before the account can be logged in.

  An `--invitable` option provides support for invitation emails, allowing the new user to create their account including password creation.

  A `--registerable` option provides support for new users to register for an account

  A `--rememberable` option provides a remember me? check box for persistent logins

  A `--migration-path` option to set the migration path

  A `--module` option to override the module

  A `--web-module` option to override the web module

  A `--installed-options` option to list the previous install options

  A `--reinstall` option to reinstall the coherence boilerplate based on your existing configuration options

  A `--silent` option to disable printing instructions

  A `--confirm-once` option to only confirm overwriting existing files once

  A `--with-migrations` option to reinstall migrations. only valid for --reinstall option

  A `--layout` (false) generate layout template and view

  A `--user-active-field` (false) add active field to user schema and disable logins when set to false.

  A `--password-hashing-alg` (Comeonin.Bcrypt) add a different password hashing algorithm

  ## Disable Options

  * `--no-config` -- Don't append to your `config/config.exs` file.
  * `--no-web` -- Don't create the `coherence_web.ex` file.
  * `--no-messages` -- Don't create the `coherence_messages.ex` file.
  * `--no-views` -- Don't create the `web/views/coherence/` files.
  * `--no-migrations` -- Don't create the migration files.
  * `--no-templates` -- Don't create the `web/templates/coherence` files.
  * `--no-boilerplate` -- Don't create any of the boilerplate files.
  * `--no-models` -- Don't generate the model file.
  * `--no-confirm` -- Don't confirm overwriting files.

  """
  use Mix.Task

  import Macro, only: [camelize: 1, underscore: 1]
  import Mix.Generator
  import Mix.Ecto
  import Coherence.Mix.Utils

  @shortdoc "Configure the Coherence Package"

  @all_options ~w(authenticatable recoverable lockable trackable trackable_table rememberable) ++
                 ~w(unlockable_with_token confirmable invitable registerable)
  @all_options_atoms Enum.map(@all_options, &String.to_atom(&1))

  @default_options ~w(authenticatable)
  @full_options @all_options -- ~w(confirmable invitable rememberable trackable_table)
  @full_confirmable @all_options -- ~w(invitable rememberable trackable_table)
  @full_invitable @all_options -- ~w(confirmable rememberable trackable_table)

  # the options that default to true, and can be disabled with --no-option
  @default_booleans ~w(config web messages views migrations templates models emails boilerplate confirm)

  # all boolean_options
  @boolean_options @default_booleans ++
                     ~w(default full full_confirmable full_invitable) ++ @all_options

  # options that will set use_email? to true
  @email_options Enum.map(
                   ~w(recoverable unlockable_with_token confirmable invitable),
                   &String.to_atom(&1)
                 )

  @config_file "config/config.exs"

  @config_marker_start "%% Coherence Configuration %%"
  @config_marker_end "%% End Coherence Configuration %%"

  @switches [
              user: :string,
              repo: :string,
              migration_path: :string,
              model: :string,
              log_only: :boolean,
              confirm_once: :boolean,
              module: :string,
              installed_options: :boolean,
              reinstall: :boolean,
              silent: :boolean,
              with_migrations: :boolean,
              router: :string,
              web_path: :string,
              web_module: :string,
              binary_id: :boolean,
              layout: :boolean,
              user_active_field: :boolean,
              password_hashing_alg: :string
            ] ++ Enum.map(@boolean_options, &{String.to_atom(&1), :boolean})

  @switch_names Enum.map(@switches, &elem(&1, 0))

  @new_user_migration_fields ["add :name, :string", "add :email, :string"]
  @new_user_constraints ["create unique_index(:users, [:email])"]

  @spec run(command_line_args :: [binary]) :: any
  def run(args) do
    {opts, parsed, unknown} = OptionParser.parse(args, switches: @switches)

    verify_deprecated!(opts)
    verify_args!(parsed, unknown)

    {bin_opts, opts} = parse_options(opts)

    opts
    |> do_config(bin_opts)
    |> do_run
  end

  defp do_run(%{reinstall: true} = config) do
    ["--no-config"]
    |> check_confirm_once(config)
    |> check_silent(config)
    |> check_migrations(config)
    |> get_config_options()
    |> run
  end

  defp do_run(%{installed_options: true} = config) do
    print_installed_options(config)
  end

  defp do_run(%{confirm_once: true} = config) do
    if Mix.shell().yes?("Are you sure you want overwrite any existing files?") do
      config
      |> Map.put(:confirm, false)
      |> Map.delete(:confirm_once)
      |> do_run
    end
  end

  defp do_run(%{with_migrations: true}),
    do: Mix.raise("--with-migrations only valid with --reinstall")

  defp do_run(config) do
    config
    |> validate_project_structure
    |> get_existing_model
    |> gen_coherence_config
    |> gen_migration
    |> gen_model
    |> gen_layout_template
    |> gen_invitable_migration
    |> gen_rememberable_migration
    |> gen_trackable_migration
    |> gen_invitable_schema
    |> gen_rememberable_schema
    |> gen_trackable_schema
    |> gen_schemas_module
    |> gen_coherence_web
    |> gen_coherence_messages
    |> gen_coherence_views
    |> gen_coherence_templates
    |> gen_coherence_mailer
    |> gen_redirects
    |> gen_responders
    # work around for config file not getting recompiled
    |> touch_config
    |> print_instructions
  end

  defp validate_project_structure(%{web_path: web_path} = config) do
    case File.lstat(web_path) do
      {:ok, %{type: :directory}} ->
        config

      _ ->
        if Mix.shell().yes?(
             "Cannot find web path #{web_path}. Are you sure you want to continue?"
           ) do
          config
        else
          Mix.raise("Cannot find web path #{web_path}")
        end
    end
  end

  defp check_confirm(options, %{confirm: true}), do: options
  defp check_confirm(options, _), do: ["--no-confirm" | options]

  defp check_confirm_once(options, %{confirm_once: false} = config),
    do: check_confirm(options, config)

  defp check_confirm_once(options, _), do: ["--confirm-once" | options]
  defp check_silent(options, %{silent: false}), do: options
  defp check_silent(options, _), do: ["--silent" | options]
  defp check_migrations(options, %{with_migrations: true}), do: options
  defp check_migrations(options, _), do: ["--no-migrations" | options]

  defp gen_coherence_config(config) do
    from_email =
      if config[:use_email?] do
        ~s|  email_from_name: "Your Name",\n| <> ~s|  email_from_email: "yourname@example.com",\n|
      else
        ""
      end

    config_block = """
    # #{@config_marker_start}   Don't remove this line
    config :coherence,
      user_schema: #{config[:user_schema]},
      repo: #{config[:repo]},
      module: #{config[:base]},
      web_module: #{config[:web_base]},
      router: #{config[:router]},
      password_hashing_alg: #{config[:password_hashing_alg]},
      messages_backend: #{config[:web_base]}.Coherence.Messages,#{layout_field(config)}
      registration_permitted_attributes: ["email","name","password","current_password","password_confirmation"],
      invitation_permitted_attributes: ["name","email"],
      password_reset_permitted_attributes: ["reset_password_token","password","password_confirmation"],
      session_permitted_attributes: ["remember","email","password"],
    """

    (config_block <> from_email <> "  opts: #{inspect(config[:opts])}\n")
    |> swoosh_config(config)
    |> add_end_marker
    |> write_config(config)
    |> log_config
  end

  defp layout_field(%{layout: true} = config),
    do: ~s(\n  layout: {#{config.web_base}.Coherence.LayoutView, "app.html"},)

  defp layout_field(_),
    do: ""

  # defp user_active_field(%{user_active_field?: true}),
  #   do: "\n  user_active_field: true,"

  # defp user_active_field(_),
  #   do: ""

  defp swoosh_config(string, %{web_base: web_base, use_email?: true}) do
    string <>
      "\n" <>
      """
      config :coherence, #{web_base}.Coherence.Mailer,
        adapter: Swoosh.Adapters.Sendgrid,
        api_key: "your api key here"
      """
  end

  defp swoosh_config(string, _), do: string

  defp add_end_marker(string) do
    string <> "# #{@config_marker_end}\n"
  end

  defp write_config(string, %{config: true, confirm: confirm?} = config) do
    log_config? =
      if File.exists?(@config_file) do
        source = File.read!(@config_file)

        confirmed =
          if String.contains?(source, @config_marker_start) do
            confirm? &&
              Mix.shell().yes?(
                "Your config file already contains Coherence configuration. Are you sure you want to add another?"
              )
          else
            true
          end

        if confirmed do
          # File.write!(@config_file, source <> "\n" <> string)
          File.write!(@config_file, format_string!(source <> "\n" <> string))
          shell_info(config, "Your config/config.exs file was updated.")
          false
        else
          shell_info(config, "Configuration was not added!")
          true
        end
      else
        shell_info(config, "Could not find #{@config_file}. Configuration was not added!")
        true
      end

    Enum.into([config_string: string, log_config?: log_config?], config)
  end

  defp write_config(string, config),
    do: Enum.into([log_config?: true, config_string: string], config)

  defp shell_info(%{silent: true} = config, _message), do: config

  defp shell_info(config, message) do
    Mix.shell().info(message)
    config
  end

  defp log_config(%{log_config?: false} = config) do
    save_instructions(config, "")
  end

  defp log_config(%{config_string: string} = config) do
    verb = if config[:log_config] == :appended, do: "has been", else: "should be"

    instructions = """

    The following #{verb} added to your #{@config_file} file.

    """

    save_instructions(config, instructions <> string)
  end

  defp touch_config(config) do
    File.touch(@config_file)
    config
  end

  defp module_to_string(module) when is_atom(module) do
    module
    |> Module.split()
    |> Enum.reverse()
    |> hd
    |> to_string
  end

  defp module_to_string(module) when is_binary(module) do
    module
    |> String.split(".")
    |> Enum.reverse()
    |> hd
  end

  ################
  # Models

  defp get_existing_model(%{user_schema: _} = config) do
    config
    |> get_compiled_model
    |> find_existing_model(lib_path())
  end

  defp get_existing_model(config), do: config

  defp get_compiled_model(%{user_schema: user_schema} = config) do
    user_schema = Module.concat(user_schema, nil)
    Map.put(config, :model_found?, ensure_compiled?(user_schema))
  end

  defp ensure_compiled?(module) do
    case Code.ensure_compiled(module) do
      {:module, _} -> true
      {:error, _} -> false
    end
  end

  def find_existing_model(%{model_found?: false, user_schema: user_schema} = config, path) do
    user_schema = Module.concat(user_schema, nil)

    model =
      user_schema
      |> Module.split()
      |> List.last()

    [path, "**", "user.ex"]
    |> Path.join()
    |> Path.wildcard()
    |> model_file_and_module(model)
    |> set_user_schema(config)
  end

  def find_existing_model(config, _path), do: config

  defp model_file_and_module(files, model) do
    Enum.reduce(files, [], fn fname, acc ->
      case File.read(fname) do
        {:ok, contents} ->
          case Regex.run(~r/defmodule\s*(.+\.#{model}) /, contents) do
            nil -> acc
            [_, module] -> [{contents, module} | acc]
          end

        {:error, _} ->
          acc
      end
    end)
  end

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

  defp set_user_schema([{contents, module} | list], config) do
    case Regex.run(~r/schema\s+"(.*)"/, contents) do
      nil ->
        set_user_schema(list, config)

      [_, table_name] ->
        config
        |> Map.put(:model_found?, true)
        |> Map.put(:user_schema, module)
        |> Map.put(:user_table_name, table_name)
    end
  end

  defp gen_model(
         %{
           user_schema: user_schema,
           boilerplate: true,
           models: true,
           model_found?: false,
           web_path: web_path
         } = config
       ) do
    name =
      user_schema
      |> String.split(".")
      |> List.last()
      |> String.downcase()

    binding = Kernel.binding() ++ [user_table_name: config[:user_table_name]] ++ config.binding

    copy_from(
      paths(),
      "priv/templates/coh.install/models/coherence",
      "",
      binding,
      [
        {:eex, "user.ex", Path.join([lib_path(), "coherence", "#{name}.ex"])}
      ],
      config
    )

    config
  end

  defp gen_model(config), do: config

  ################
  # Migrations

  defp create_or_alter_model(config, name) do
    table_name = config[:user_table_name]
    # user_schema = Module.concat user_schema, nil
    if config[:model_found?] do
      {:alter, "add_coherence_to_#{name}", [], []}
    else
      fields =
        Enum.map(
          @new_user_migration_fields,
          &String.replace(&1, ":users", ":#{table_name}")
        )

      constraints =
        Enum.map(
          @new_user_constraints,
          &String.replace(&1, ":users", ":#{table_name}")
        )

      {:create, "create_coherence_#{name}", fields, constraints}
    end
  end

  # defp model_exists?(model, path) do
  # end

  defp add_timestamp(acc, %{model_found?: false}), do: acc ++ ["", "timestamps()"]
  defp add_timestamp(acc, _), do: acc

  defp get_field_list(initial_fields, config) do
    schema_fields = schema_fields(config)

    Enum.reduce(config[:opts], initial_fields, fn opt, acc ->
      case schema_fields[opt] do
        nil -> acc
        list -> acc ++ list
      end
    end)
  end

  defp gen_migration(%{migrations: true, boilerplate: true} = config) do
    table_name = config[:user_table_name]

    name =
      config[:user_schema]
      |> module_to_string
      |> String.downcase()

    {verb, migration_name, initial_fields, constraints} = create_or_alter_model(config, name)

    do_gen_migration(config, migration_name, fn repo, _path, file, name ->
      field_list = get_field_list(initial_fields, config)

      adds =
        field_list
        |> add_timestamp(config)
        |> Enum.map(&("      " <> &1))
        |> Enum.join("\n")

      constraints =
        constraints
        |> Enum.map(&("    " <> &1))
        |> Enum.join("\n")

      statement =
        case verb do
          :alter -> "#{verb} table(:#{table_name}) do"
          :create -> gen_table_statement(table_name)
        end

      change = """
          #{statement}
      #{adds}
          end
      #{constraints}
      """

      assigns = [mod: Module.concat([repo, Migrations, camelize(name)]), change: change]
      create_file(file, migration_template(assigns))
    end)
  end

  defp gen_migration(config), do: config

  defp gen_invitable_migration(%{invitable: true, migrations: true, boilerplate: true} = config) do
    do_gen_migration(config, "create_coherence_invitable", fn repo, _path, file, name ->
      change = """
          #{gen_table_statement(:invitations)}
            add :name, :string
            add :email, :string
            add :token, :string
            timestamps()
          end
          create unique_index(:invitations, [:email])
          create index(:invitations, [:token])
      """

      assigns = [mod: Module.concat([repo, Migrations, camelize(name)]), change: change]
      create_file(file, migration_template(assigns))
    end)
  end

  defp gen_invitable_migration(config), do: config

  defp gen_rememberable_migration(
         %{rememberable: true, migrations: true, boilerplate: true} = config
       ) do
    table_name = config[:user_table_name]

    do_gen_migration(config, "create_coherence_rememberable", fn repo, _path, file, name ->
      change = """
          #{gen_table_statement(:rememberables)}
            add :series_hash, :string
            add :token_hash, :string
            add :token_created_at, :utc_datetime
            add :user_id, #{gen_reference(table_name)}

            timestamps()
          end
          create index(:rememberables, [:user_id])
          create index(:rememberables, [:series_hash])
          create index(:rememberables, [:token_hash])
          create unique_index(:rememberables, [:user_id, :series_hash, :token_hash])
      """

      assigns = [mod: Module.concat([repo, Migrations, camelize(name)]), change: change]
      create_file(file, migration_template(assigns))
    end)
  end

  defp gen_rememberable_migration(config), do: config

  defp gen_trackable_migration(
         %{trackable_table: true, migrations: true, boilerplate: true} = config
       ) do
    table_name = config[:user_table_name]

    do_gen_migration(config, "create_coherence_trackable", fn repo, _path, file, name ->
      change = """
          #{gen_table_statement(:trackables)}
            add :action, :string
            add :sign_in_count, :integer, default: 0
            add :current_sign_in_at, :utc_datetime
            add :last_sign_in_at, :utc_datetime
            add :current_sign_in_ip, :string
            add :last_sign_in_ip, :string
            add :user_id, #{gen_reference(table_name)}

            timestamps()
          end
          create index(:trackables, [:user_id])
          create index(:trackables, [:action])
      """

      assigns = [mod: Module.concat([repo, Migrations, camelize(name)]), change: change]
      create_file(file, migration_template(assigns))
    end)
  end

  defp gen_trackable_migration(config), do: config

  defp do_gen_migration(%{timestamp: current_timestamp} = config, name, fun) do
    repo =
      config[:repo]
      |> String.split(".")
      |> Module.concat()

    ensure_repo(repo, [])

    path =
      case config[:migration_path] do
        path when is_binary(path) ->
          path

        _ ->
          Path.relative_to(migrations_path(repo), Mix.Project.app_path())
      end

    file = Path.join(path, "#{current_timestamp}_#{underscore(name)}.exs")
    fun.(repo, path, file, name)
    Map.put(config, :timestamp, current_timestamp + 1)
  end

  defp gen_table_statement(table_name) do
    if use_binary_id?() do
      """
      create table(:#{table_name}, primary_key: false) do
            add :id, :binary_id, primary_key: true
      """
    else
      """
      create table(:#{table_name}) do
      """
    end
  end

  defp gen_reference(table_name) do
    type_hint = if use_binary_id?(), do: ", type: :binary_id", else: ""
    "references(:#{table_name}, on_delete: :delete_all#{type_hint})"
  end

  ################
  # Schema

  defp gen_trackable_schema(%{trackable_table: true, boilerplate: true, models: true} = config) do
    gen_schema_schema(config, "trackable.ex")
  end

  defp gen_trackable_schema(config), do: config

  defp gen_invitable_schema(%{invitable: true, boilerplate: true, models: true} = config) do
    gen_schema_schema(config, "invitation.ex")
  end

  defp gen_invitable_schema(config), do: config

  defp gen_rememberable_schema(%{rememberable: true, boilerplate: true, models: true} = config) do
    gen_schema_schema(config, "rememberable.ex")
  end

  defp gen_rememberable_schema(config), do: config

  def gen_schema_schema(config, file_name) do
    copy_from(
      paths(),
      "priv/templates/coh.install/models/coherence",
      lib_path("coherence"),
      config.binding,
      [
        {:eex, file_name, file_name}
      ],
      config
    )

    config
  end

  def gen_schemas_module(%{boilerplate: true, models: true} = config) do
    schema_list =
      [
        {Invitation, config[:invitable]},
        {Rememberable, config[:rememberable]},
        {Trackable, config[:trackable_table]}
      ]
      |> Enum.filter(&elem(&1, 1))
      |> Enum.map(&Module.concat([config.base, Coherence, elem(&1, 0)]))

    binding = [
      {:schema_list, schema_list |> inspect},
      {:trackable?, config[:trackable_table]} | config.binding
    ]

    copy_from(
      paths(),
      "priv/templates/coh.install/models/coherence",
      lib_path("coherence"),
      binding,
      [
        {:eex, "schemas.ex", "schemas.ex"}
      ],
      config
    )

    config
  end

  def gen_schemas_module(config), do: config

  ################
  # Web

  defp gen_coherence_web(
         %{web: true, boilerplate: true, binding: binding, web_path: web_path} = config
       ) do
    copy_from(
      paths(),
      "priv/templates/coh.install",
      "",
      binding,
      [
        {:eex, "coherence_web.ex", Path.join(web_path, "coherence_web.ex")}
      ],
      config
    )

    config
  end

  defp gen_coherence_web(config), do: config

  ################
  # Messages

  defp gen_coherence_messages(
         %{messages: true, boilerplate: true, binding: binding, web_path: web_path} = config
       ) do
    copy_from(
      paths(),
      "priv/templates/coh.install",
      "",
      binding,
      [
        {:eex, "coherence_messages.ex", Path.join(web_path, "coherence_messages.ex")}
      ],
      config
    )

    config
  end

  defp gen_coherence_messages(config), do: config

  defp gen_redirects(%{boilerplate: true, binding: binding, web_path: web_path} = config) do
    copy_from(
      paths(),
      "priv/templates/coh.install/controllers/coherence",
      "",
      binding,
      [
        {:eex, "redirects.ex", Path.join(web_path, "controllers/coherence/redirects.ex")}
      ],
      config
    )

    config
  end

  defp gen_redirects(config), do: config

  defp gen_responders(%{boilerplate: true, binding: binding, web_path: web_path} = config) do
    copy_from(
      paths(),
      "priv/templates/coh.install/controllers/coherence/responders",
      "",
      binding,
      [
        {:eex, "html.ex", Path.join(web_path, "controllers/coherence/responders/html.ex")},
        {:eex, "json.ex", Path.join(web_path, "controllers/coherence/responders/json.ex")}
      ],
      config
    )

    config
  end

  defp gen_responders(config), do: config

  ################
  # Views

  @view_files [
    all: "coherence_view.ex",
    confirmable: "confirmation_view.ex",
    use_email?: "email_view.ex",
    invitable: "invitation_view.ex",
    all: "layout_view.ex",
    all: "coherence_view_helpers.ex",
    recoverable: "password_view.ex",
    registerable: "registration_view.ex",
    authenticatable: "session_view.ex",
    unlockable_with_token: "unlock_view.ex"
  ]

  def view_files, do: @view_files

  def gen_coherence_views(
        %{views: true, boilerplate: true, binding: binding, web_path: web_path} = config
      ) do
    files =
      @view_files
      |> Enum.filter(&validate_option(config, elem(&1, 0)))
      |> Enum.map(&elem(&1, 1))
      |> Enum.map(&{:eex, &1, Path.join(web_path, "views/coherence/#{&1}")})

    copy_from(
      paths(),
      "priv/templates/coh.install/views/coherence",
      "",
      [{:otp_app, Mix.Phoenix.otp_app()} | binding],
      files,
      config
    )

    config
  end

  def gen_coherence_views(config), do: config

  @template_files [
    email: {:use_email?, ~w(confirmation invitation password unlock)},
    invitation: {:invitable, ~w(edit new)},
    # layout: {:all, ~w(app email)},
    layout: {:all, ~w(email)},
    password: {:recoverable, ~w(edit new)},
    registration: {:registerable, ~w(new edit form show)},
    session: {:authenticatable, ~w(new)},
    unlock: {:unlockable_with_token, ~w(new)},
    confirmation: {:confirmable, ~w(new)}
  ]

  def template_files, do: @template_files

  defp validate_option(_, :all), do: true
  defp validate_option(%{use_email?: true}, :use_email?), do: true

  defp validate_option(%{opts: opts}, opt) do
    if opt in opts, do: true, else: false
  end

  ################
  # Templates

  def gen_coherence_templates(%{templates: true, boilerplate: true, binding: binding} = config) do
    for {name, {opt, files}} <- @template_files do
      if validate_option(config, opt), do: copy_templates(binding, name, files, config)
    end

    config
  end

  def gen_coherence_templates(config), do: config

  def gen_layout_template(%{layout: true, templates: true, boilerplate: true} = config) do
    copy_templates(config.binding, :layout, ["app"], config)
    config
  end

  def gen_layout_template(config), do: config

  defp copy_templates(binding, name, file_list, %{web_path: web_path} = config) do
    files =
      for fname <- file_list do
        fname = "#{fname}.html.eex"
        {:eex, fname, Path.join(web_path, "templates/coherence/#{name}/#{fname}")}
      end

    copy_from(
      paths(),
      "priv/templates/coh.install/templates/coherence/#{name}",
      "",
      binding,
      files,
      config
    )
  end

  ################
  # Mailer

  defp gen_coherence_mailer(
         %{binding: binding, use_email?: true, boilerplate: true, web_path: web_path} = config
       ) do
    copy_from(
      paths(),
      "priv/templates/coh.install/emails/coherence",
      "",
      binding,
      [
        {:eex, "coherence_mailer.ex",
         Path.join(web_path, "emails/coherence/coherence_mailer.ex")},
        {:eex, "user_email.ex", Path.join(web_path, "emails/coherence/user_email.ex")}
      ],
      config
    )

    config
  end

  defp gen_coherence_mailer(config), do: config

  ################
  # Instructions

  defp seeds_instructions(%{repo: repo, user_schema: user_schema, authenticatable: true} = config) do
    user_schema = to_string(user_schema)
    repo = to_string(repo)

    block = """
    You might want to add the following to your priv/repo/seeds.exs file.

    #{repo}.delete_all(#{user_schema})

    %#{user_schema}{}
    |> #{user_schema}.changeset(%{
      name: "Test User",
      email: "testuser@example.com",
      password: "secret",
      password_confirmation: "secret"
    })
    |> #{repo}.insert!()
    """

    confirm = if config[:confirmable], do: "|> Coherence.Controller.confirm!()\n", else: ""
    block <> confirm
  end

  defp seeds_instructions(_config), do: ""

  defp schema_instructions(%{base: base, found_model?: false}),
    do: """
    Add the following items to your User model (Phoenix v1.2).

    defmodule #{base}.User do
      use Ecto.Schema
      # Add this
      use Coherence.Schema

      schema "users" do
        field :name, :string
        field :email, :string
        # Add this
        coherence_schema()

        timestamps()
      end

      def changeset(model, params \\ %{}) do
        model
        |> cast(params, [:name, :email] ++ coherence_fields)
        |> validate_required([:name, :email])
        |> unique_constraint(:email)
        # Add this
        |> validate_coherence(params)
      end

      def changeset(model, params, :password) do
        model
        |> cast(
          params,
          ~w(password password_confirmation reset_password_token reset_password_sent_at)
        )
        |> validate_coherence_password_reset(params)
      end
    end
    """

  defp schema_instructions(_), do: ""

  defp mix_instructions(%{base: base}),
    do: """
      Add :coherence to your applications list in mix.exs.

      def application do
        [mod: {#{base}, []},
         extra_applications: [..., :coherence]]
      end
    """

  defp router_instructions(config) do
    router = config[:router]
    web_base = config[:web_base]

    """
    Add the following to your router.ex file.

    defmodule #{router} do
      use #{web_base}, :router
      # Add this
      use Coherence.Router

      pipeline :browser do
        plug(:accepts, ["html"])
        plug(:fetch_session)
        plug(:fetch_flash)
        plug(:protect_from_forgery)
        plug(:put_secure_browser_headers)
        # Add this
        plug(Coherence.Authentication.Session)
      end

      # Add this block
      pipeline :protected do
        plug(:accepts, ["html"])
        plug(:fetch_session)
        plug(:fetch_flash)
        plug(:protect_from_forgery)
        plug(:put_secure_browser_headers)
        plug(Coherence.Authentication.Session, protected: true)
      end

      # Add this block
      scope "/" do
        pipe_through(:browser)
        coherence_routes()
      end

      # Add this block
      scope "/" do
        pipe_through(:protected)
        coherence_routes(:protected)
      end

      scope "/", #{web_base} do
        pipe_through(:browser)
        get("/", PageController, :index)
        # Add public routes below
      end

      scope "/", #{web_base} do
        pipe_through(:protected)
        # Add protected routes below
      end
    end
    """
  end

  defp migrate_instructions(%{migrations: true, boilerplate: true}) do
    """
    Don't forget to run the new migrations and seeds with:
        $ mix ecto.setup
    """
  end

  defp migrate_instructions(_), do: ""

  defp print_instructions(%{silent: true} = config), do: config

  defp print_instructions(%{instructions: instructions} = config) do
    Mix.shell().info(instructions)
    Mix.shell().info(mix_instructions(config))
    Mix.shell().info(router_instructions(config))
    Mix.shell().info(schema_instructions(config))
    Mix.shell().info(seeds_instructions(config))
    Mix.shell().info(migrate_instructions(config))

    config
  end

  defp timestamp do
    {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time()
    "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}"
  end

  embed_template(:migration, """
  defmodule <%= inspect @mod %> do
    use Ecto.Migration
    def change do
  <%= @change %>
    end
  end
  """)

  ################
  # Utilities

  defp pad(i) when i < 10, do: <<?0, ?0 + i>>
  defp pad(i), do: to_string(i)

  defp do_default_config(config, opts) do
    @default_booleans
    |> list_to_atoms
    |> Enum.reduce(config, fn opt, acc ->
      Map.put(acc, opt, Keyword.get(opts, opt, true))
    end)
  end

  defp list_to_atoms(list), do: Enum.map(list, &String.to_atom(&1))

  defp paths do
    [".", :coherence]
  end

  defp save_instructions(config, instructions) do
    update_in(config, [:instructions], &(&1 <> instructions))
  end

  ################
  # Installer Configuration

  defp do_config(opts, []) do
    do_config(opts, list_to_atoms(@default_options))
  end

  defp do_config(opts, bin_opts) do
    binding =
      Mix.Project.config()
      |> Keyword.fetch!(:app)
      |> Atom.to_string()
      |> Mix.Phoenix.inflect()

    base = opts[:module] || binding[:base]
    web_base = opts[:web_module] || base <> "Web"
    opts = Keyword.put(opts, :base, base)
    repo = opts[:repo] || "#{base}.Repo"
    router = opts[:router] || "#{web_base}.Router"
    web_path = opts[:web_path] || web_path()
    web_module = web_base <> ".Coherence"
    password_hashing_alg = opts[:password_hashing_alg] || "Comeonin.Bcrypt"

    binding =
      binding
      |> Keyword.put(:base, base)
      |> Keyword.put(:web_base, web_base)
      |> Keyword.put(:web_path, web_path)
      |> Keyword.put(:web_module, web_module)
      |> Keyword.put(:otp_app, Mix.Phoenix.otp_app())
      |> Keyword.put(:use_binary_id?, use_binary_id?())
      |> Keyword.put(:user_active_field?, opts[:user_active_field])

    {user_schema, user_table_name} = parse_model(opts[:model], base, opts)

    opts_map = do_bin_opts(bin_opts)

    user_email = Enum.any?(bin_opts, &(&1 in @email_options))
    the_timestamp = String.to_integer(timestamp())

    [
      instructions: "",
      base: base,
      use_email?: user_email,
      user_schema: user_schema,
      user_table_name: user_table_name,
      repo: repo,
      router: router,
      opts: bin_opts,
      binding: binding,
      log_only: opts[:log_only],
      migration_path: opts[:migration_path],
      module: opts[:module],
      timestamp: the_timestamp,
      installed_options: opts[:installed_options],
      confirm: opts[:confirm],
      confirm_once: opts[:confirm_once],
      reinstall: opts[:reinstall],
      silent: opts[:silent],
      with_migrations: opts[:with_migrations],
      web_path: web_path,
      web_base: web_base,
      web_module: web_module,
      use_binary_id?: binding[:use_binary_id?],
      layout: opts[:layout] || false,
      user_active_field?: binding[:user_active_field?],
      password_hashing_alg: password_hashing_alg
    ]
    |> Enum.into(opts_map)
    |> do_default_config(opts)
  end

  defp do_bin_opts(bin_opts) do
    bin_opts
    |> Enum.map(&{&1, true})
    |> Enum.into(%{})
  end

  defp parse_model(model, _base, opts) when is_binary(model) do
    case String.split(model, " ", trim: true) do
      [model, table] ->
        {prefix_model(model, opts), String.to_atom(table)}

      [_] ->
        Mix.raise("""
        The mix coh.install --model option expects both singular and plural names. For example:

            mix coh.install --model="Account accounts"
        """)
    end
  end

  defp parse_model(_, base, _) do
    {"#{base}.Coherence.User", :users}
  end

  defp prefix_model(model, opts) do
    module = opts[:module] || opts[:base]

    if String.starts_with?(model, module) do
      model
    else
      module <> "." <> model
    end
  end

  defp option_reduce({:default, true}, {acc_bin, acc}),
    do: {list_to_atoms(@default_options) ++ acc_bin, acc}

  defp option_reduce({:full, true}, {acc_bin, acc}),
    do: {list_to_atoms(@full_options) ++ acc_bin, acc}

  defp option_reduce({:full_confirmable, true}, {acc_bin, acc}),
    do: {list_to_atoms(@full_confirmable) ++ acc_bin, acc}

  defp option_reduce({:full_invitable, true}, {acc_bin, acc}),
    do: {list_to_atoms(@full_invitable) ++ acc_bin, acc}

  defp option_reduce({:trackable_table, true}, {acc_bin, acc}),
    do: {[:trackable_table | acc_bin] -- [:trackable], acc}

  defp option_reduce({name, true}, {acc_bin, acc}) when name in @all_options_atoms,
    do: {[name | acc_bin], acc}

  defp option_reduce({name, false}, {acc_bin, acc}) when name in @all_options_atoms,
    do: {acc_bin -- [name], acc}

  defp option_reduce(opt, {acc_bin, acc}),
    do: {acc_bin, [opt | acc]}

  defp parse_options(opts) do
    {opts_bin, opts} = Enum.reduce(opts, {[], []}, &option_reduce(&1, &2))

    opts_bin = Enum.uniq(opts_bin)

    opts_names = Enum.map(opts, &elem(&1, 0))

    with [] <- Enum.filter(opts_bin, &(not (&1 in @switch_names))),
         [] <- Enum.filter(opts_names, &(not (&1 in @switch_names))) do
      {opts_bin, opts}
    else
      list -> raise_option_errors(list)
    end
  end

  def all_options, do: @all_options_atoms

  def print_installed_options(_config) do
    ["mix coh.install"]
    |> list_config_options(Application.get_env(:coherence, :opts, []))
    |> Enum.reverse()
    |> Enum.join(" ")
    |> Mix.shell().info
  end

  def list_config_options(acc, opts) do
    Enum.reduce(opts, acc, &config_option/2)
  end

  def get_config_options([]) do
    Mix.raise("""
    Could not find coherence configuration.
    """)
  end

  def get_config_options(opts) do
    :coherence
    |> Application.get_env(:opts, [])
    |> get_config_options(opts)
  end

  def get_config_options([], _opts) do
    Mix.raise("""
    Could not find coherence configuration for re-installation. Please remove the --reinstall option to do a fresh install.
    """)
  end

  def get_config_options(config_opts, opts) do
    Enum.reduce(config_opts, opts, &config_option/2)
  end

  defp config_option(opt, acc) when is_atom(opt) do
    str = opt |> Atom.to_string() |> String.replace("_", "-")
    ["--" <> str | acc]
  end

  defp config_option(opt, acc) when is_tuple(opt) do
    str = opt |> elem(0) |> Atom.to_string() |> String.replace("_", "-")
    ["--" <> str | acc]
  end

  @doc """
  Copies files from source dir to target dir
  according to the given map.
  Files are evaluated against EEx according to
  the given binding.
  """
  def copy_from(apps, source_dir, target_dir, binding, mapping, config) when is_list(mapping) do
    roots = Enum.map(apps, &to_app_source(&1, source_dir))

    create_opts = if config[:confirm], do: [], else: [force: true]

    for {format, source_file_path, target_file_path} <- mapping do
      source =
        Enum.find_value(roots, fn root ->
          source = Path.join(root, source_file_path)
          if File.exists?(source), do: source
        end) || raise("could not find #{source_file_path} in any of the sources")

      target = Path.join(target_dir, target_file_path)

      contents =
        case format do
          :text -> File.read!(source)
          :eex -> EEx.eval_file(source, binding)
        end

      Mix.Generator.create_file(target, contents, create_opts)
    end
  end

  defp to_app_source(path, source_dir) when is_binary(path),
    do: Path.join(path, source_dir)

  defp to_app_source(app, source_dir) when is_atom(app),
    do: Application.app_dir(app, source_dir)

  defp lib_path(path \\ "") do
    Path.join(["lib", to_string(Mix.Phoenix.otp_app()), path])
  end

  defp web_path(path \\ "") do
    otp_app = to_string(Mix.Phoenix.otp_app())
    Path.join(["lib", otp_app <> "_web", path])
  end

  defp use_binary_id? do
    binary_ids? =
      Mix.Phoenix.otp_app()
      |> Application.get_env(:generators, [])
      |> Keyword.get(:binary_id)

    Coherence.Config.use_binary_id?() || binary_ids?
  end

  defp verify_deprecated!(opts) do
    if opts[:controllers] do
      Mix.raise("--controllers not supported.
         Please use mix coh.gen.controllers instead.")
    end
  end
end