lib/glific/csv/file.ex

defmodule Glific.CSV.File do
  @moduledoc """
  First implementation to convert sheets to flows using a menu structure and UUID
  """
  use Ecto.Schema

  alias Glific.{
    CSV.Content,
    CSV.Flow,
    CSV.Menu,
    Partners.Organization
  }

  @type t :: %__MODULE__{
          __meta__: Ecto.Schema.Metadata.t(),
          id: non_neg_integer | nil,
          name: String.t() | nil,
          contents: String.t() | nil,
          uuid_map: map() | nil,
          main_menu: map() | nil,
          organization_id: non_neg_integer | nil,
          organization: Organization.t() | Ecto.Association.NotLoaded.t() | nil,
          inserted_at: :utc_datetime | nil,
          updated_at: :utc_datetime | nil
        }

  schema "csv_files" do
    field :name, :string

    field :contents, :string

    field :uuid_map, :map

    field :main_menu, :map, virtual: true

    belongs_to :organization, Organization

    timestamps(type: :utc_datetime)
  end

  @doc """
  Read a csv file, and split it up into a bunch of tuples that we are interested in. Assuming
  that the csv file is valid for now
  """
  @spec process_csv_file(String.t(), String.t(), non_neg_integer) :: map()
  def process_csv_file(file, output, organization_id) do
    summary =
      file
      |> Path.expand(__DIR__)
      |> File.stream!()
      |> CSV.decode()
      |> Enum.drop(3)
      |> Enum.map(fn {:ok, l} -> l end)
      |> parse_header()
      |> parse_rows(%{})

    json_map =
      Flow.gen_flow(summary.menus[0], organization_id, main_menu_item: true, back_menu_item: false)

    {:ok, json} = Jason.encode_to_iodata(json_map, pretty: true)

    :ok =
      output
      |> Path.expand(__DIR__)
      |> File.write(json)

    summary
    |> Map.put(:root, summary.menus[0])
    |> Map.put(:json_map, json_map)
    |> Map.delete(:menus)
  end

  @doc """
  Given a header, extract the indexes of the language, menu and content
  items which helps us when parsing each row
  """
  @spec parse_header(list()) :: {list(), map()}
  def parse_header([header | _rest] = rows) do
    meta_data = %{
      language: get_languages(header),
      menu: get_keyword_maps(header, "menu"),
      content: get_keyword_maps(header, "content"),
      label: get_keyword_maps(header, "label", true)
    }

    {rows, meta_data}
  end

  @spec get_languages(list()) :: map()
  defp get_languages(header) do
    header
    |> Enum.reduce(
      %{},
      fn r, acc ->
        s = String.split(r, ":", parts: 2, trim: true)

        if length(s) != 2,
          do: acc,
          else: Map.put(acc, hd(s), true)
      end
    )
  end

  # get the mapping of menu and content items to their column position
  @spec get_keyword_maps(list(), String.t(), boolean()) :: map()
  defp get_keyword_maps(header, key, ignore_language \\ false) do
    header
    |> Enum.with_index()
    |> Enum.reduce(
      %{},
      fn {r, index}, acc ->
        s = String.split(r, ":", parts: 3, trim: true)

        if length(s) != 3 or Enum.at(s, 1) != key do
          acc
        else
          [language, _, menu_idx] = s
          menu_idx = String.to_integer(menu_idx)

          if ignore_language do
            Map.put(acc, menu_idx, index)
          else
            idx = Map.get(acc, menu_idx, %{})
            Map.put(acc, menu_idx, Map.put(idx, language, index))
          end
        end
      end
    )
  end

  @spec parse_rows({list(), map()}, map()) :: map()
  defp parse_rows({rows, header_data}, summary) do
    rest = tl(rows)

    # lets hardcode this for the flow, to make it easier to import
    # into our db and hence flow-editor
    # root_uuid = Ecto.UUID.generate()
    {:ok, root_uuid} = Ecto.UUID.cast("8a67330c-8cf6-498f-93fb-d771e675ff22")

    root = %Menu{
      uuids: %{
        node: Ecto.UUID.generate(),
        action: Ecto.UUID.generate(),
        exit: Ecto.UUID.generate(),
        router: Ecto.UUID.generate(),
        label: Ecto.UUID.generate(),
        parent: nil,
        root: root_uuid
      },
      sr_no: 0,
      level: 0,
      position: 0,
      content: %{},
      menu_content: nil,
      content_item: nil,
      sub_menus: []
    }

    summary =
      summary
      |> Map.put(:header_data, header_data)
      |> Map.put(:menus, %{0 => root})
      |> Map.put(:positions, %{0 => 0, 1 => 0})

    rest
    |> Enum.reduce(
      summary,
      fn r, acc ->
        parse_row(r, acc)
      end
    )
  end

  defp parse_row([_num, active | rest] = row, summary) do
    cond do
      active == "FALSE" -> summary
      Enum.all?(rest, fn x -> x == "" or is_nil(x) end) -> summary
      true -> parse_valid_row(row, summary)
    end
  end

  defp parse_valid_row([num, _active | _rest] = row, summary) do
    header_data = summary.header_data
    num = String.to_integer(num)

    menu_opts = %{
      sr_no: num,
      level: 0,
      position: 0
    }

    menu_content = content_item(row, header_data.menu, menu_opts)
    leaf_menu_idx = Enum.max(Map.keys(menu_content))

    # get the labels
    labels = get_labels(row, header_data.label)

    # initialize position of content items
    content_opts = %{
      sr_no: num,
      # since we start numbering from 0 internally
      level: leaf_menu_idx * 2 + 1,
      position: Map.get(summary.positions, leaf_menu_idx, 0)
    }

    content_item = content_item(row, header_data.content, content_opts)
    positions = Map.put(summary.positions, leaf_menu_idx, content_opts.position + 1)
    summary = Map.put(summary, :positions, positions)

    # create menu entries for each of the menu_content items
    # in sorted order
    Enum.reduce(
      menu_content,
      summary,
      fn {idx, menu}, summary ->
        {item, content, level, position, summary} =
          if idx == leaf_menu_idx do
            c = hd(Map.values(content_item))
            {content_item, build_content_map(content_item), c.level, c.position, summary}
          else
            position = Map.get(summary.positions, idx, 0)
            positions = Map.put(summary.positions, idx, position + 1)
            summary = Map.put(summary, :positions, positions)
            {nil, %{}, summary.menus[idx - 1].level + 2, position, summary}
          end

        sub_menu =
          create_menu(
            summary.menus[0].uuids.node,
            summary.menus[idx - 1].uuids.node,
            sr_no: num,
            label: labels[idx],
            level: level,
            position: position,
            menu_content: menu,
            content_item: item,
            content: content
          )

        # keep track of the latest menu for this level
        # we append the next higher level submenus here
        parent_menu =
          summary.menus[idx - 1]
          |> Map.update(:sub_menus, [sub_menu], fn m -> m ++ [sub_menu] end)
          |> Map.update!(:content, fn c -> merge_menu_content(c, sub_menu.menu_content) end)

        menus =
          summary.menus
          |> Map.put(idx, sub_menu)
          |> Map.put(idx - 1, parent_menu)
          |> update_ancestors(parent_menu, idx - 2)

        Map.put(summary, :menus, menus)
      end
    )
  end

  defp update_ancestors(menus, _leaf, idx) when idx < 0, do: menus

  defp update_ancestors(menus, leaf, idx) do
    # update the parent at the leaf id
    parent = Map.get(menus, idx)
    parent = Map.update!(parent, :sub_menus, fn m -> List.update_at(m, -1, fn _l -> leaf end) end)
    menus = Map.put(menus, idx, parent)

    # do it for its ancestor also
    update_ancestors(menus, parent, idx - 1)
  end

  defp create_menu(root, parent, attrs) do
    defaults = [
      uuids: %{
        node: Ecto.UUID.generate(),
        action: Ecto.UUID.generate(),
        exit: Ecto.UUID.generate(),
        router: Ecto.UUID.generate(),
        label: Ecto.UUID.generate(),
        parent: parent,
        root: root
      },
      position: 0,
      level: 0,
      sub_menus: [],
      content_items: []
    ]

    struct(Menu, Keyword.merge(defaults, attrs))
  end

  defp get_labels(row, header_map) do
    Enum.reduce(
      header_map,
      %{},
      fn {idx, col}, acc -> Map.put(acc, idx, Enum.at(row, col)) end
    )
  end

  # get the content items from the row, and create the content structure
  # return as an array of content items
  defp content_item(row, header_map, opts) do
    Enum.reduce(
      header_map,
      %{},
      fn {idx, values}, acc ->
        content = get_content_value(row, values)

        if empty?(content) do
          acc
        else
          Map.put(acc, idx, %Content{
            sr_no: opts.sr_no,
            level: opts.level,
            position: opts.position,
            content: content
          })
        end
      end
    )
  end

  defp empty?(content),
    do: Enum.all?(content, fn {_k, v} -> v == "" or is_nil(v) end)

  defp get_content_value(row, header_map) do
    Enum.reduce(
      header_map,
      %{},
      fn {language, col}, acc ->
        Map.put(acc, language, Enum.at(row, col))
      end
    )
  end

  defp build_content_map(content) do
    Enum.reduce(
      content,
      %{},
      fn {idx, cont}, acc ->
        merge_content_map(idx, cont, acc)
      end
    )
  end

  defp merge_content_map(idx, cont, acc) do
    Enum.reduce(
      cont.content,
      acc,
      fn {lang, text}, acc ->
        Map.update(
          acc,
          lang,
          %{idx => text},
          fn l -> Map.put(l, idx, text) end
        )
      end
    )
  end

  defp merge_menu_content(main_menu, sub_menu) do
    Enum.reduce(
      sub_menu.content,
      main_menu,
      fn {lang, text}, acc ->
        Map.update(acc, lang, %{sub_menu.sr_no => text}, fn m ->
          Map.put(m, sub_menu.sr_no, text)
        end)
      end
    )
  end
end