defmodule Yugo.Client do
  @moduledoc """
  A persistent connection to an IMAP server.

  Normally you do not call the functions in this module directly, but rather start a [`Client`](`Yugo.Client`) as part
  of your application's supervision tree. For example:

      defmodule MyApp.Application do
        use Application

        @impl true
        def start(_type, _args) do
          children = [
            {Yugo.Client,
             name: :example_client,
             server: "imap.example.com",
             username: "me@example.com",
             password: "pa55w0rd"}
          ]

          Supervisor.start_link(children, strategy: :one_for_one)
        end
      end

  See [`start_link`](`Yugo.Client.start_link/1`) for a list of possible arguments.
  """

  use GenServer
  alias Yugo.{Conn, Parser, Filter}

  @typedoc """
  The identifier used to refer to a [`Client`](`Yugo.Client`).
  """
  @type name :: term

  @doc """
  Starts an IMAP client process linked to the calling process.

  Takes arguments as a keyword list.

  ## Arguments

    * `:username` - Required. Username used to log in.

    * `:password` - Required. Password used to log in.

    * `:name` - Required. A name used to reference this [`Client`](`Yugo.Client`). Can be any term.

    * `:server` - Required. The location of the IMAP server, e.g. `"imap.example.com"`.

    * `:port` - The port to connect to the server via. Defaults to `993`.

    * `:tls` - Whether or not to connect using TLS. Defaults to `true`. If you set this to `false`,
    Yugo will make the initial connection without TLS, then upgrade to a TLS connection (using STARTTLS)
    before logging in. Yugo will never send login credentials over an insecure connection.

    * `:mailbox` - The name of the mailbox to monitor for emails. Defaults to `"INBOX"`.
    The default "INBOX" mailbox is defined in the IMAP standard. If your account has other mailboxes,
    you can pass the name of one as a string. A single [`Client`](`Yugo.Client`) can only monitor a single mailbox -
    to monitor multiple mailboxes, you need to start multiple [`Client`](`Yugo.Client`)s.

  ### Advanced Arguments

    The following options are provided because they can be useful, but in most cases you won't
    need to change them from the default, unless you know what you're doing.

    * `:ssl_verify` - The `:verify` option passed to `:ssl.connect/2`. Can be `:verify_peer` or `:verify_none`.
    Defaults to `:verify_peer`.

  ## Example

  Normally, you do not call this function directly, but rather run it as part of your application's supervision tree.
  See the top of this page for example `Application` usage.
  """
  @spec start_link(
          server: String.t(),
          username: String.t(),
          password: String.t(),
          name: name,
          port: 1..65535,
          tls: boolean,
          ssl_verify: :verify_none | :verify_peer,
          mailbox: String.t()
        ) :: GenServer.on_start()
  def start_link(args) do
    for required <- [:server, :username, :password, :name] do
      Keyword.has_key?(args, required) || raise "Missing required argument `:#{required}`."
    end

    args =
      args
      |> Keyword.put_new(:port, 993)
      |> Keyword.put_new(:tls, true)
      |> Keyword.put_new(:mailbox, "INBOX")
      |> Keyword.put_new(:ssl_verify, :verify_peer)
      |> Keyword.update!(:server, &to_charlist/1)

    args[:ssl_verify] in [:verify_peer, :verify_none] ||
      raise ":ssl_verify option must be one of: :verify_peer, :verify_none"

    name = {:via, Registry, {Yugo.Registry, args[:name]}}
    GenServer.start_link(__MODULE__, args, name: name)
  end

  @common_connect_opts [packet: :line, active: :once, mode: :binary]

  defp ssl_opts(server, ssl_verify),
    do:
      [
        server_name_indication: server,
        verify: ssl_verify,
        cacerts: :public_key.cacerts_get()
      ] ++ @common_connect_opts

  @impl true
  def init(args) do
    {:ok, nil, {:continue, args}}
  end

  @impl true
  def terminate(_reason, conn) do
    conn
    |> send_command("LOGOUT")
  end

  @impl true
  def handle_cast({:subscribe, pid, filter}, conn) do
    conn =
      %{conn | filters: [{filter, pid} | conn.filters]}
      |> update_attrs_needed_by_filters()

    {:noreply, conn}
  end

  @impl true
  def handle_cast({:unsubscribe, pid}, conn) do
    conn =
      %{conn | filters: Enum.reject(conn.filters, &(elem(&1, 1) == pid))}
      |> update_attrs_needed_by_filters()

    {:noreply, conn}
  end

  @impl true
  def handle_continue(args, _state) do
    {:ok, socket} =
      if args[:tls] do
        :ssl.connect(
          args[:server],
          args[:port],
          ssl_opts(args[:server], args[:ssl_verify])
        )
      else
        :gen_tcp.connect(args[:server], args[:port], @common_connect_opts)
      end

    conn = %Conn{
      my_name: args[:name],
      tls: args[:tls],
      socket: socket,
      server: args[:server],
      username: args[:username],
      password: args[:password],
      mailbox: args[:mailbox],
      ssl_verify: args[:ssl_verify]
    }

    {:noreply, conn}
  end

  @impl true
  def handle_info({socket_kind, socket, data}, conn) when socket_kind in [:ssl, :tcp] do
    data = recv_literals(conn, [data])

    # we set [active: :once] each time so that we can parse packets that have synchronizing literals spanning over multiple lines
    :ok =
      if conn.tls do
        :ssl.setopts(socket, active: :once)
      else
        :inet.setopts(socket, active: :once)
      end

    %Conn{} = conn = handle_packet(data, conn)

    {:noreply, conn}
  end

  @impl true
  def handle_info({close_message, _sock}, conn)
      when close_message in [:tcp_closed, :ssl_closed] do
    {:stop, :normal, conn}
  end

  @noop_poll_interval 5000
  @impl true
  def handle_info(:poll_with_noop, conn) do
    Process.send_after(self(), :poll_with_noop, @noop_poll_interval)

    conn =
      if command_in_progress?(conn) do
        conn
      else
        conn
        |> send_command("NOOP")
      end

    {:noreply, conn}
  end

  @idle_timeout 1000 * 60 * 27
  @impl true
  def handle_info(:idle_timeout, conn) do
    conn =
      %{conn | idle_timed_out: true}
      |> cancel_idle()

    {:noreply, conn}
  end

  defp update_attrs_needed_by_filters(conn) do
    attrs =
      [
        Enum.any?(conn.filters, fn {f, _} -> Filter.needs_flags?(f) end) && "FLAGS",
        Enum.any?(conn.filters, fn {f, _} -> Filter.needs_envelope?(f) end) && "ENVELOPE"
      ]
      |> Enum.reject(&(&1 == false))
      |> Enum.join(" ")

    %{conn | attrs_needed_by_filters: attrs}
  end

  # If the previously received line ends with `{123}` (a synchronizing literal), parse more lines until we
  # have at least 123 bytes. If the line ends with another `{123}`, repeat the process.
  defp recv_literals(%Conn{} = conn, [prev | _] = acc, n_remaining \\ 0) do
    if n_remaining <= 0 do
      # n_remaining <= 0 - we don't need any more bytes to fulfil the previous literal. We might be done...
      case Regex.run(~r/\{(\d+)\}\r\n$/, prev, capture: :all_but_first) do
        [n] ->
          # ...unless there is another literal.
          # +2 so that we make sure we get the full command (either the last 2 \r\n, or the next part of the command)
          n = String.to_integer(n) + 2
          recv_literals(conn, acc, n)

        _ ->
          # The last line didn't end with a literal. The packet is complete.
          acc
          |> Enum.reverse()
          |> Enum.join()
      end
    else
      # we need more bytes to complete the current literal. Recv the next line.
      {:ok, next_line} =
        if conn.tls do
          :ssl.recv(conn.socket, 0)
        else
          :gen_tcp.recv(conn.socket, 0)
        end

      recv_literals(conn, [next_line | acc], n_remaining - byte_size(next_line))
    end
  end

  defp handle_packet(data, conn) do
    if conn.got_server_greeting do
      actions = Parser.parse_response(data)

      conn =
        conn
        |> apply_actions(actions)

      conn =
        if conn.idling and conn.unprocessed_messages != %{} do
          conn
          |> cancel_idle()
        else
          conn
        end

      conn
      |> maybe_process_messages()
      |> maybe_idle()
    else
      # ignore the first message from the server, which is the unsolicited greeting
      %{conn | got_server_greeting: true}
      |> send_command("CAPABILITY", &on_unauthed_capability_response/3)
    end
  end

  defp on_unauthed_capability_response(conn, :ok, _text) do
    if !conn.tls do
      if "STARTTLS" in conn.capabilities do
        conn
        |> send_command("STARTTLS", &on_starttls_response/3)
      else
        raise "Server does not support STARTTLS as required by RFC3501."
      end
    else
      conn
      |> do_login()
    end
  end

  defp on_starttls_response(conn, :ok, _text) do
    {:ok, socket} = :ssl.connect(conn.socket, ssl_opts(conn.server, conn.ssl_verify), :infinity)

    %{conn | tls: true, socket: socket}
    |> do_login()
  end

  defp do_login(conn) do
    conn
    |> send_command(
      "LOGIN #{quote_string(conn.username)} #{quote_string(conn.password)}",
      &on_login_response/3
    )
    |> Map.put(:password, "")
  end

  defp on_login_response(conn, :ok, _text) do
    %{conn | state: :authenticated}
    |> send_command("CAPABILITY", &on_authed_capability_response/3)
  end

  defp on_authed_capability_response(conn, :ok, _text) do
    conn
    |> send_command("SELECT #{quote_string(conn.mailbox)}", &on_select_response/3)
  end

  defp on_select_response(conn, :ok, text) do
    conn = %{conn | state: :selected}

    if Regex.match?(~r/^\[READ-ONLY\]/i, text) do
      %{conn | mailbox_mutability: :read_only}
    else
      %{conn | mailbox_mutability: :read_write}
    end
    |> maybe_noop_poll()
  end

  defp command_in_progress?(conn), do: conn.tag_map != %{}

  # starts NOOP polling unless the server supports IDLE
  defp maybe_noop_poll(conn) do
    unless "IDLE" in conn.capabilities do
      send(self(), :poll_with_noop)
    end

    conn
  end

  defp on_idle_response(conn, :ok, _text) do
    if conn.idle_timed_out do
      maybe_idle(conn)
    else
      conn
    end
  end

  # IDLEs if there is no command in progress, we're not already idling, and the server supports IDLE
  defp maybe_idle(conn) do
    if "IDLE" in conn.capabilities and not command_in_progress?(conn) and not conn.idling do
      timer = Process.send_after(self(), :idle_timeout, @idle_timeout)

      %{conn | idling: true, idle_timer: timer, idle_timed_out: false}
      |> send_command("IDLE", &on_idle_response/3)
    else
      conn
    end
  end

  defp cancel_idle(conn) do
    Process.cancel_timer(conn.idle_timer)

    %{conn | idling: false, idle_timer: nil}
    |> send_raw("DONE\r\n")
  end

  defp maybe_process_messages(conn) do
    if command_in_progress?(conn) or conn.unprocessed_messages == %{} or conn.state != :selected do
      conn
    else
      process_earliest_message(conn)
    end
  end

  defp process_earliest_message(conn) do
    {seqnum, msg} = Enum.min_by(conn.unprocessed_messages, fn {k, _v} -> k end)

    cond do
      not Map.has_key?(msg, :fetched) ->
        conn
        |> fetch_message(seqnum)
        |> maybe_process_messages()

      msg.fetched == :filter ->
        parts_to_fetch =
          [flags: "FLAGS", envelope: "ENVELOPE"]
          |> Enum.reject(fn {key, _} -> Map.has_key?(msg, key) end)
          |> Enum.map(&elem(&1, 1))

        parts_to_fetch = ["BODY" | parts_to_fetch]

        conn =
          conn
          |> put_in([Access.key!(:unprocessed_messages), seqnum, :fetched], :pre_body)

        unless Enum.empty?(parts_to_fetch) do
          conn
          |> send_command("FETCH #{seqnum} (#{Enum.join(parts_to_fetch, " ")})")
        else
          conn
        end

      msg.fetched == :pre_body ->
        body_parts =
          body_part_paths(msg.body_structure)
          |> Enum.map(&"BODY.PEEK[#{&1}]")

        conn
        |> send_command("FETCH #{seqnum} (#{Enum.join(body_parts, " ")})", fn conn, :ok, _text ->
          put_in(conn, [Access.key!(:unprocessed_messages), seqnum, :fetched], :full)
        end)

      msg.fetched == :full ->
        conn
        |> release_message(seqnum)
    end
  end

  defp body_part_paths(body_structure, path_acc \\ []) do
    case body_structure do
      {:onepart, _body} ->
        path =
          if path_acc == [] do
            "1"
          else
            path_acc
            |> Enum.reverse()
            |> Enum.join(".")
          end

        [path]

      {:multipart, bodies} ->
        bodies
        |> Enum.with_index(1)
        |> Enum.flat_map(fn {b, idx} -> body_part_paths(b, [idx | path_acc]) end)
    end
  end

  # Removes the message from conn.unprocessed_messages and sends it to subscribers with matching filters
  defp release_message(conn, seqnum) do
    {msg, conn} = pop_in(conn, [Access.key!(:unprocessed_messages), seqnum])

    for {filter, pid} <- conn.filters do
      if Filter.accepts?(filter, msg) do
        send(pid, {:email, conn.my_name, package_message(msg)})
      end
    end

    conn
  end

  # Preprocesses/cleans the message before it is sent to a subscriber
  defp package_message(msg) do
    msg
    |> Map.merge(msg.envelope)
    |> Map.drop([:fetched, :body_structure, :envelope])
    |> Map.put(:body, normalize_structure(msg.body, msg.body_structure))
  end

  defp normalize_structure(msg_body, msg_structure) do
    combine_bodies_if_multipart(msg_body)
    |> get_part_structures(msg_structure)
  end

  defp combine_bodies_if_multipart(_, depth \\ 0)
  defp combine_bodies_if_multipart([body], _depth), do: body
  defp combine_bodies_if_multipart(body, _depth) when is_tuple(body), do: body

  defp combine_bodies_if_multipart(bodies, depth) when is_list(bodies) and length(bodies) > 1 do
    bodies
    |> Enum.group_by(fn {path, _} -> Enum.at(path, depth) end)
    |> Map.values()
    |> Enum.map(&combine_bodies_if_multipart(&1, depth + 1))
  end

  defp get_part_structures({_, content}, {:onepart, map}),
    do: {map.mime_type, map.params, Parser.decode_body(content, map.encoding)}

  defp get_part_structures({[idx | path], content}, {:multipart, parts}),
    do: get_part_structures({path, content}, Enum.at(parts, idx - 1))

  defp get_part_structures(bodies, structure) when is_list(bodies),
    do: Enum.map(bodies, &get_part_structures(&1, structure))

  # FETCHes the message attributes needed to apply filters
  defp fetch_message(conn, seqnum) do
    conn =
      conn
      |> put_in([Access.key!(:unprocessed_messages), seqnum, :fetched], :filter)

    if conn.attrs_needed_by_filters == "" do
      conn
    else
      conn
      |> send_command("FETCH #{seqnum} (#{conn.attrs_needed_by_filters})")
    end
  end

  defp apply_action(conn, action) do
    case action do
      {:capabilities, caps} ->
        %{conn | capabilities: caps}

      {:tagged_response, {tag, status, text}} when status == :ok ->
        {%{on_response: resp_fn}, conn} = pop_in(conn, [Access.key!(:tag_map), tag])

        resp_fn.(conn, status, text)

      {:tagged_response, {tag, status, text}} when status in [:bad, :no] ->
        raise "Got `#{status |> to_string() |> String.upcase()}` response status: `#{text}`. Command that caused this response: `#{conn.tag_map[tag].command}`"

      :continuation ->
        conn

      {:applicable_flags, flags} ->
        %{conn | applicable_flags: flags}

      {:permanent_flags, flags} ->
        %{conn | permanent_flags: flags}

      {:num_exists, num} ->
        conn =
          if conn.num_exists < num do
            %{
              conn
              | unprocessed_messages:
                  Map.merge(
                    Map.from_keys(Enum.to_list((conn.num_exists + 1)..num), %{}),
                    conn.unprocessed_messages
                  )
            }
          else
            conn
          end

        %{conn | num_exists: num}

      {:num_recent, num} ->
        %{conn | num_recent: num}

      {:first_unseen, num} ->
        %{conn | first_unseen: num}

      {:uid_validity, num} ->
        %{conn | uid_validity: num}

      {:uid_next, num} ->
        %{conn | uid_next: num}

      {:expunge, expunged_num} ->
        %{
          conn
          | num_exists: conn.num_exists - 1,
            unprocessed_messages:
              conn.unprocessed_messages
              |> Enum.reject(fn {k, _v} -> k == expunged_num end)
              |> Enum.map(fn {k, v} ->
                cond do
                  expunged_num < k ->
                    {k - 1, v}

                  expunged_num > k ->
                    {k, v}
                end
              end)
              |> Map.new()
        }

      {:fetch, {seq_num, :flags, flags}} ->
        if Map.has_key?(conn.unprocessed_messages, seq_num) do
          flags = Parser.system_flags_to_atoms(flags)

          conn
          |> put_in([Access.key!(:unprocessed_messages), seq_num, :flags], flags)
        else
          conn
        end

      {:fetch, {seq_num, :envelope, envelope}} ->
        if Map.has_key?(conn.unprocessed_messages, seq_num) do
          conn
          |> put_in([Access.key!(:unprocessed_messages), seq_num, :envelope], envelope)
        else
          conn
        end

      {:fetch, {seq_num, :body, one_or_mpart}} ->
        if Map.has_key?(conn.unprocessed_messages, seq_num) do
          conn
          |> put_in(
            [Access.key!(:unprocessed_messages), seq_num, :body_structure],
            one_or_mpart
          )
        else
          conn
        end

      {:fetch, {seq_num, :body_content, {body_number, content}}} ->
        msg = Map.get(conn.unprocessed_messages, seq_num)

        if msg do
          body =
            case msg.body_structure do
              {:onepart, _} ->
                {body_number, content}

              {:multipart, _} ->
                [{body_number, content} | msg[:body] || []]
            end

          conn
          |> put_in([Access.key!(:unprocessed_messages), seq_num, :body], body)
        else
          conn
        end

      {:fetch, {_seq_num, :uid, _uid}} ->
        conn
    end
  end

  defp apply_actions(conn, []), do: conn

  defp apply_actions(conn, [action | rest]),
    do: conn |> apply_action(action) |> apply_actions(rest)

  defp send_raw(conn, stuff) do
    if conn.tls do
      :ssl.send(conn.socket, stuff)
    else
      :gen_tcp.send(conn.socket, stuff)
    end

    conn
  end

  defp send_command(conn, cmd, on_response \\ fn conn, _status, _text -> conn end) do
    tag = conn.next_cmd_tag
    cmd = "#{tag} #{cmd}\r\n"

    send_raw(conn, cmd)
    |> Map.put(:next_cmd_tag, tag + 1)
    |> put_in([Access.key!(:tag_map), tag], %{command: cmd, on_response: on_response})
  end

  defp quote_string(string) do
    if Regex.match?(~r/[\r\n]/, string) do
      raise "string passed to quote_string contains a CR or LF. TODO: support literals"
    end

    string
    |> String.replace("\\", "\\\\")
    |> String.replace(~S("), ~S(\"))
    |> then(&~s("#{&1}"))
  end
end
