defmodule Mix.Phoenix.Schema do
  @moduledoc false

  alias Mix.Phoenix.Schema

  defstruct module: nil,
            repo: nil,
            repo_alias: nil,
            table: nil,
            collection: nil,
            embedded?: false,
            generate?: true,
            opts: [],
            alias: nil,
            file: nil,
            attrs: [],
            string_attr: nil,
            plural: nil,
            singular: nil,
            uniques: [],
            redacts: [],
            assocs: [],
            types: [],
            indexes: [],
            defaults: [],
            human_singular: nil,
            human_plural: nil,
            binary_id: false,
            migration_defaults: nil,
            migration?: false,
            params: %{},
            optionals: [],
            sample_id: nil,
            web_path: nil,
            web_namespace: nil,
            context_app: nil,
            route_helper: nil,
            route_prefix: nil,
            api_route_prefix: nil,
            migration_module: nil,
            fixture_unique_functions: [],
            fixture_params: [],
            prefix: nil,
            timestamp_type: :naive_datetime

  @valid_types [
    :integer,
    :float,
    :decimal,
    :boolean,
    :map,
    :string,
    :array,
    :references,
    :text,
    :date,
    :time,
    :time_usec,
    :naive_datetime,
    :naive_datetime_usec,
    :utc_datetime,
    :utc_datetime_usec,
    :uuid,
    :binary,
    :enum
  ]

  def valid_types, do: @valid_types

  def valid?(schema) do
    schema =~ ~r/^[A-Z]\w*(\.[A-Z]\w*)*$/
  end

  def new(schema_name, schema_plural, cli_attrs, opts) do
    ctx_app = opts[:context_app] || Mix.Phoenix.context_app()
    otp_app = Mix.Phoenix.otp_app()
    opts = Keyword.merge(Application.get_env(otp_app, :generators, []), opts)
    base = Mix.Phoenix.context_base(ctx_app)
    basename = Phoenix.Naming.underscore(schema_name)
    module = Module.concat([base, schema_name])
    repo = opts[:repo] || Module.concat([base, "Repo"])
    repo_alias = if String.ends_with?(Atom.to_string(repo), ".Repo"), do: "", else: ", as: Repo"
    file = Mix.Phoenix.context_lib_path(ctx_app, basename <> ".ex")
    table = opts[:table] || schema_plural
    {cli_attrs, uniques, redacts} = extract_attr_flags(cli_attrs)
    {assocs, attrs} = partition_attrs_and_assocs(module, attrs(cli_attrs))
    types = types(attrs)
    web_namespace = opts[:web] && Phoenix.Naming.camelize(opts[:web])
    web_path = web_namespace && Phoenix.Naming.underscore(web_namespace)
    api_prefix = Application.get_env(otp_app, :generators)[:api_prefix] || "/api"
    embedded? = Keyword.get(opts, :embedded, false)
    generate? = Keyword.get(opts, :schema, true)

    singular =
      module
      |> Module.split()
      |> List.last()
      |> Phoenix.Naming.underscore()

    collection = if schema_plural == singular, do: singular <> "_collection", else: schema_plural
    string_attr = string_attr(types)
    create_params = params(attrs, :create)

    optionals = for {key, :map} <- types, do: key, into: []

    default_params_key =
      case Enum.at(create_params, 0) do
        {key, _} -> key
        nil -> :some_field
      end

    fixture_unique_functions = fixture_unique_functions(singular, uniques, attrs)

    %Schema{
      opts: opts,
      migration?: Keyword.get(opts, :migration, true),
      module: module,
      repo: repo,
      repo_alias: repo_alias,
      table: table,
      embedded?: embedded?,
      alias: module |> Module.split() |> List.last() |> Module.concat(nil),
      file: file,
      attrs: attrs,
      plural: schema_plural,
      singular: singular,
      collection: collection,
      optionals: optionals,
      assocs: assocs,
      types: types,
      defaults: schema_defaults(attrs),
      uniques: uniques,
      redacts: redacts,
      indexes: indexes(table, assocs, uniques),
      human_singular: Phoenix.Naming.humanize(singular),
      human_plural: Phoenix.Naming.humanize(schema_plural),
      binary_id: opts[:binary_id],
      timestamp_type: opts[:timestamp_type] || :naive_datetime,
      migration_defaults: migration_defaults(attrs),
      string_attr: string_attr,
      params: %{
        create: create_params,
        update: params(attrs, :update),
        default_key: string_attr || default_params_key
      },
      web_namespace: web_namespace,
      web_path: web_path,
      route_helper: route_helper(web_path, singular),
      route_prefix: route_prefix(web_path, schema_plural),
      api_route_prefix: api_route_prefix(web_path, schema_plural, api_prefix),
      sample_id: sample_id(opts),
      context_app: ctx_app,
      generate?: generate?,
      migration_module: migration_module(),
      fixture_unique_functions: Enum.sort(fixture_unique_functions),
      fixture_params: fixture_params(attrs, fixture_unique_functions),
      prefix: opts[:prefix]
    }
  end

  @doc """
  Returns the string value of the default schema param.
  """
  def default_param(%Schema{} = schema, action) do
    schema.params
    |> Map.fetch!(action)
    |> Map.fetch!(schema.params.default_key)
    |> to_string()
  end

  def extract_attr_flags(cli_attrs) do
    {attrs, uniques, redacts} =
      Enum.reduce(cli_attrs, {[], [], []}, fn attr, {attrs, uniques, redacts} ->
        [attr_name | rest] = String.split(attr, ":")
        attr_name = String.to_atom(attr_name)
        split_flags(Enum.reverse(rest), attr_name, attrs, uniques, redacts)
      end)

    {Enum.reverse(attrs), uniques, redacts}
  end

  defp split_flags(["unique" | rest], name, attrs, uniques, redacts),
    do: split_flags(rest, name, attrs, [name | uniques], redacts)

  defp split_flags(["redact" | rest], name, attrs, uniques, redacts),
    do: split_flags(rest, name, attrs, uniques, [name | redacts])

  defp split_flags(rest, name, attrs, uniques, redacts),
    do: {[Enum.join([name | Enum.reverse(rest)], ":") | attrs], uniques, redacts}

  @doc """
  Parses the attrs as received by generators.
  """
  def attrs(attrs) do
    Enum.map(attrs, fn attr ->
      attr
      |> String.split(":", parts: 3)
      |> list_to_attr()
      |> validate_attr!()
    end)
  end

  @doc """
  Generates some sample params based on the parsed attributes.
  """
  def params(attrs, action \\ :create) when action in [:create, :update] do
    Map.new(attrs, fn {k, t} -> {k, type_to_default(k, t, action)} end)
  end

  @doc """
  Converts the given value to map format when it is a date, time, datetime or naive_datetime.

  Since `form_component.html.heex` generated by the live generator uses selects for dates and/or
  times, fixtures must use map format for those fields in order to submit the live form.
  """
  def live_form_value(%Date{} = date), do: Calendar.strftime(date, "%Y-%m-%d")

  def live_form_value(%Time{} = time), do: Calendar.strftime(time, "%H:%M")

  def live_form_value(%NaiveDateTime{} = naive) do
    NaiveDateTime.to_iso8601(naive)
  end

  def live_form_value(%DateTime{} = naive) do
    DateTime.to_iso8601(naive)
  end

  def live_form_value(value), do: value

  @doc """
  Build an invalid value for `@invalid_attrs` which is nil by default.

  * In case the value is a list, this will return an empty array.
  * In case the value is date, datetime, naive_datetime or time, this will return an invalid date.
  * In case it is a boolean, we keep it as false
  """
  def invalid_form_value(value) when is_list(value), do: []

  def invalid_form_value(%{day: _day, month: _month, year: _year} = _date),
    do: "2022-00"

  def invalid_form_value(%{hour: _hour, minute: _minute}), do: %{hour: 14, minute: 00}
  def invalid_form_value(true), do: false
  def invalid_form_value(_value), do: nil

  @doc """
  Generates an invalid error message according to the params present in the schema.
  """
  def failed_render_change_message(_schema) do
    "can&#39;t be blank"
  end

  def type_for_migration({:enum, _}), do: :string
  def type_for_migration(other), do: other

  def format_fields_for_schema(schema) do
    Enum.map_join(schema.types, "\n", fn {k, v} ->
      "    field #{inspect(k)}, #{type_and_opts_for_schema(v)}#{schema.defaults[k]}#{maybe_redact_field(k in schema.redacts)}"
    end)
  end

  @doc """
  Return the required fields in the schema. Anything not in the `optionals` list
  is considered required.
  """
  def required_fields(schema) do
    Enum.reject(schema.attrs, fn {key, _} -> key in schema.optionals end)
  end

  def type_and_opts_for_schema({:enum, opts}),
    do: ~s|Ecto.Enum, values: #{inspect(Keyword.get(opts, :values))}|

  def type_and_opts_for_schema(other), do: inspect(other)

  def maybe_redact_field(true), do: ", redact: true"
  def maybe_redact_field(false), do: ""

  @doc """
  Returns the string value for use in EEx templates.
  """
  def value(schema, field, value) do
    schema.types
    |> Map.fetch!(field)
    |> inspect_value(value)
  end

  defp inspect_value(:decimal, value), do: "Decimal.new(\"#{value}\")"
  defp inspect_value(_type, value), do: inspect(value)

  defp list_to_attr([key]), do: {String.to_atom(key), :string}
  defp list_to_attr([key, value]), do: {String.to_atom(key), String.to_atom(value)}

  defp list_to_attr([key, comp, value]) do
    {String.to_atom(key), {String.to_atom(comp), String.to_atom(value)}}
  end

  @one_day_in_seconds 24 * 3600

  defp type_to_default(key, t, :create) do
    case t do
      {:array, type} ->
        build_array_values(type, :create)

      {:enum, values} ->
        build_enum_values(values, :create)

      :integer ->
        42

      :float ->
        120.5

      :decimal ->
        "120.5"

      :boolean ->
        true

      :map ->
        %{}

      :text ->
        "some #{key}"

      :date ->
        Date.add(Date.utc_today(), -1)

      :time ->
        ~T[14:00:00]

      :time_usec ->
        ~T[14:00:00.000000]

      :uuid ->
        "7488a646-e31f-11e4-aace-600308960662"

      :utc_datetime ->
        DateTime.add(
          build_utc_datetime(),
          -@one_day_in_seconds,
          :second,
          Calendar.UTCOnlyTimeZoneDatabase
        )

      :utc_datetime_usec ->
        DateTime.add(
          build_utc_datetime_usec(),
          -@one_day_in_seconds,
          :second,
          Calendar.UTCOnlyTimeZoneDatabase
        )

      :naive_datetime ->
        NaiveDateTime.add(build_utc_naive_datetime(), -@one_day_in_seconds)

      :naive_datetime_usec ->
        NaiveDateTime.add(build_utc_naive_datetime_usec(), -@one_day_in_seconds)

      _ ->
        "some #{key}"
    end
  end

  defp type_to_default(key, t, :update) do
    case t do
      {:array, type} -> build_array_values(type, :update)
      {:enum, values} -> build_enum_values(values, :update)
      :integer -> 43
      :float -> 456.7
      :decimal -> "456.7"
      :boolean -> false
      :map -> %{}
      :text -> "some updated #{key}"
      :date -> Date.utc_today()
      :time -> ~T[15:01:01]
      :time_usec -> ~T[15:01:01.000000]
      :uuid -> "7488a646-e31f-11e4-aace-600308960668"
      :utc_datetime -> build_utc_datetime()
      :utc_datetime_usec -> build_utc_datetime_usec()
      :naive_datetime -> build_utc_naive_datetime()
      :naive_datetime_usec -> build_utc_naive_datetime_usec()
      _ -> "some updated #{key}"
    end
  end

  defp build_array_values(:string, :create),
    do: Enum.map([1, 2], &"option#{&1}")

  defp build_array_values(:integer, :create),
    do: [1, 2]

  defp build_array_values(:string, :update),
    do: ["option1"]

  defp build_array_values(:integer, :update),
    do: [1]

  defp build_array_values(_, _),
    do: []

  defp build_enum_values(values, action) do
    case {action, translate_enum_vals(values)} do
      {:create, vals} -> hd(vals)
      {:update, [val | []]} -> val
      {:update, vals} -> vals |> tl() |> hd()
    end
  end

  defp build_utc_datetime_usec,
    do: %{DateTime.utc_now() | second: 0, microsecond: {0, 6}}

  defp build_utc_datetime,
    do: DateTime.truncate(build_utc_datetime_usec(), :second)

  defp build_utc_naive_datetime_usec,
    do: %{NaiveDateTime.utc_now() | second: 0, microsecond: {0, 6}}

  defp build_utc_naive_datetime,
    do: NaiveDateTime.truncate(build_utc_naive_datetime_usec(), :second)

  @enum_missing_value_error """
  Enum type requires at least one value
  For example:

      mix phx.gen.schema Comment comments body:text status:enum:published:unpublished
  """

  defp validate_attr!({name, :datetime}), do: validate_attr!({name, :naive_datetime})

  defp validate_attr!({name, :array}) do
    Mix.raise("""
    Phoenix generators expect the type of the array to be given to #{name}:array.
    For example:

        mix phx.gen.schema Post posts settings:array:string
    """)
  end

  defp validate_attr!({_name, :enum}), do: Mix.raise(@enum_missing_value_error)
  defp validate_attr!({_name, type} = attr) when type in @valid_types, do: attr
  defp validate_attr!({_name, {:enum, _vals}} = attr), do: attr
  defp validate_attr!({_name, {type, _}} = attr) when type in @valid_types, do: attr

  defp validate_attr!({_, type}) do
    Mix.raise(
      "Unknown type `#{inspect(type)}` given to generator. " <>
        "The supported types are: #{@valid_types |> Enum.sort() |> Enum.join(", ")}"
    )
  end

  defp partition_attrs_and_assocs(schema_module, attrs) do
    {assocs, attrs} =
      Enum.split_with(attrs, fn
        {_, {:references, _}} ->
          true

        {key, :references} ->
          Mix.raise("""
          Phoenix generators expect the table to be given to #{key}:references.
          For example:

              mix phx.gen.schema Comment comments body:text post_id:references:posts
          """)

        _ ->
          false
      end)

    assocs =
      Enum.map(assocs, fn {key_id, {:references, source}} ->
        key = String.replace(Atom.to_string(key_id), "_id", "")
        base = schema_module |> Module.split() |> Enum.drop(-1)
        module = Module.concat(base ++ [Phoenix.Naming.camelize(key)])
        {String.to_atom(key), key_id, inspect(module), source}
      end)

    {assocs, attrs}
  end

  defp schema_defaults(attrs) do
    Enum.into(attrs, %{}, fn
      {key, :boolean} -> {key, ", default: false"}
      {key, _} -> {key, ""}
    end)
  end

  defp string_attr(types) do
    Enum.find_value(types, fn
      {key, :string} -> key
      _ -> false
    end)
  end

  defp types(attrs) do
    Enum.into(attrs, %{}, fn
      {key, {:enum, vals}} -> {key, {:enum, values: translate_enum_vals(vals)}}
      {key, {root, val}} -> {key, {root, schema_type(val)}}
      {key, val} -> {key, schema_type(val)}
    end)
  end

  def translate_enum_vals(vals) do
    vals
    |> Atom.to_string()
    |> String.split(":")
    |> Enum.map(&String.to_atom/1)
  end

  defp schema_type(:text), do: :string
  defp schema_type(:uuid), do: Ecto.UUID

  defp schema_type(val) do
    if Code.ensure_loaded?(Ecto.Type) and not Ecto.Type.primitive?(val) do
      Mix.raise("Unknown type `#{val}` given to generator")
    else
      val
    end
  end

  defp indexes(table, assocs, uniques) do
    uniques = Enum.map(uniques, fn key -> {key, true} end)
    assocs = Enum.map(assocs, fn {_, key, _, _} -> {key, false} end)

    (uniques ++ assocs)
    |> Enum.uniq_by(fn {key, _} -> key end)
    |> Enum.map(fn
      {key, false} -> "create index(:#{table}, [:#{key}])"
      {key, true} -> "create unique_index(:#{table}, [:#{key}])"
    end)
  end

  defp migration_defaults(attrs) do
    Enum.into(attrs, %{}, fn
      {key, :boolean} -> {key, ", default: false, null: false"}
      {key, _} -> {key, ""}
    end)
  end

  defp sample_id(opts) do
    if Keyword.get(opts, :binary_id, false) do
      Keyword.get(opts, :sample_binary_id, "11111111-1111-1111-1111-111111111111")
    else
      -1
    end
  end

  defp route_helper(web_path, singular) do
    "#{web_path}_#{singular}"
    |> String.trim_leading("_")
    |> String.replace("/", "_")
  end

  defp route_prefix(web_path, plural) do
    path = Path.join(for str <- [web_path, plural], do: to_string(str))
    "/" <> String.trim_leading(path, "/")
  end

  defp api_route_prefix(web_path, plural, api_prefix) do
    path = Path.join(for str <- [api_prefix, web_path, plural], do: to_string(str))
    "/" <> String.trim_leading(path, "/")
  end

  defp migration_module do
    case Application.get_env(:ecto_sql, :migration_module, Ecto.Migration) do
      migration_module when is_atom(migration_module) -> migration_module
      other -> Mix.raise("Expected :migration_module to be a module, got: #{inspect(other)}")
    end
  end

  defp fixture_unique_functions(singular, uniques, attrs) do
    uniques
    |> Enum.filter(&Keyword.has_key?(attrs, &1))
    |> Enum.into(%{}, fn attr ->
      function_name = "unique_#{singular}_#{attr}"

      {function_def, needs_impl?} =
        case Keyword.fetch!(attrs, attr) do
          :integer ->
            function_def =
              """
                def #{function_name}, do: System.unique_integer([:positive])
              """

            {function_def, false}

          type when type in [:string, :text] ->
            function_def =
              """
                def #{function_name}, do: "some #{attr}\#{System.unique_integer([:positive])}"
              """

            {function_def, false}

          _ ->
            function_def =
              """
                def #{function_name} do
                  raise "implement the logic to generate a unique #{singular} #{attr}"
                end
              """

            {function_def, true}
        end

      {attr, {function_name, function_def, needs_impl?}}
    end)
  end

  defp fixture_params(attrs, fixture_unique_functions) do
    attrs
    |> Enum.sort()
    |> Enum.map(fn {attr, type} ->
      case fixture_unique_functions do
        %{^attr => {function_name, _function_def, _needs_impl?}} ->
          {attr, "#{function_name}()"}

        %{} ->
          {attr, inspect(type_to_default(attr, type, :create))}
      end
    end)
  end
end
