# Copyright (C) 2007, Dave Watson <dwatson@mimvista.com>
# Copyright (C) 2007, Robin Rosenberg <robin.rosenberg@dewire.com>
# Copyright (C) 2006-2008, Shawn O. Pearce <spearce@spearce.org>
# and other copyright owners as documented in the project's IP log.
#
# Elixir adaptation from jgit file:
# org.eclipse.jgit/src/org/eclipse/jgit/lib/PersonIdent.java
#
# Copyright (C) 2019, Eric Scouten <eric+xgit@scouten.com>
#
# This program and the accompanying materials are made available
# under the terms of the Eclipse Distribution License v1.0 which
# accompanies this distribution, is reproduced below, and is
# available at http://www.eclipse.org/org/documents/edl-v10.php
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or
# without modification, are permitted provided that the following
# conditions are met:
#
# - Redistributions of source code must retain the above copyright
#   notice, this list of conditions and the following disclaimer.
#
# - Redistributions in binary form must reproduce the above
#   copyright notice, this list of conditions and the following
#   disclaimer in the documentation and/or other materials provided
#   with the distribution.
#
# - Neither the name of the Eclipse Foundation, Inc. nor the
#   names of its contributors may be used to endorse or promote
#   products derived from this software without specific prior
#   written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
# OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
# ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

defmodule Xgit.PersonIdent do
  @moduledoc ~S"""
  A combination of a person identity and time in git.
  """

  alias Xgit.Util.ParseCharlist
  alias Xgit.Util.ParseDecimal

  import Xgit.Util.ForceCoverage

  @typedoc "Time zone offset in minutes +/- from GMT."
  @type tz_offset :: -720..840

  @typedoc ~S"""
  The tuple of name, email, time, and time zone that specifies who wrote or
  committed something.

  ## Struct Members

  * `:name`: (string) human-readable name of the author or committer
  * `:email`: (string) e-mail address for this person
  * `:when`: (integer) time in the Unix epoch in milliseconds
  * `:tz_offset`: (integer) time zone offset from GMT in minutes
  """
  @type t :: %__MODULE__{
          name: String.t(),
          email: String.t(),
          when: integer,
          tz_offset: tz_offset()
        }

  @enforce_keys [:name, :email, :when, :tz_offset]
  defstruct [:name, :email, :when, :tz_offset]

  @doc ~S"""
  Parse a name line (e.g. author, committer, tagger) into a `PersonIdent` struct.

  ## Parameters

  `b` should be a charlist of an "author" or "committer" line pointing to the
  character after the header name and space.

  ## Return Value

  Returns a `PersonIdent` struct or `nil` if the charlist did not point to a
  properly-formatted identity.
  """
  @spec from_byte_list(b :: [byte]) :: t() | nil
  def from_byte_list(b) when is_list(b) do
    with {_, [?< | email_start]} <- Enum.split_while(b, &(&1 != ?<)),
         true <- has_closing_angle_bracket?(email_start),
         {email, _} <- Enum.split_while(email_start, &(&1 != ?>)),
         name <- parse_name(b),
         {time, tz} <- parse_tz(email_start) do
      %__MODULE__{
        name: ParseCharlist.decode_ambiguous_charlist(name),
        email: ParseCharlist.decode_ambiguous_charlist(email),
        when: time,
        tz_offset: tz
      }
    else
      _ -> cover nil
    end
  end

  defp has_closing_angle_bracket?(b), do: Enum.any?(b, &(&1 == ?>))

  defp parse_name(b) do
    b
    |> Enum.take_while(&(&1 != ?<))
    |> Enum.reverse()
    |> drop_first_if_space()
    |> Enum.reverse()
  end

  defp drop_first_if_space([?\s | b]), do: cover(b)
  defp drop_first_if_space(b), do: cover(b)

  defp parse_tz(first_email_start) do
    # Start searching from end of line, as after first name-email pair,
    # another name-email pair may occur. We will ignore all kinds of
    # "junk" following the first email.

    [?> | first_email_end] = Enum.drop_while(first_email_start, &(&1 != ?>))
    rev = Enum.reverse(first_email_end)

    {tz, rev} = trim_word_and_rev(rev)
    {time, _rev} = trim_word_and_rev(rev)

    case {time, tz} do
      {[_ | _], [_ | _]} ->
        {time |> ParseDecimal.from_decimal_charlist() |> elem(0),
         tz |> parse_timezone_offset() |> elem(0)}

      _ ->
        cover {0, 0}
    end
  end

  defp trim_word_and_rev(rev) do
    rev = Enum.drop_while(rev, &(&1 == ?\s))

    word =
      rev
      |> Enum.take_while(&(&1 != ?\s))
      |> Enum.reverse()

    cover {word, Enum.drop(rev, Enum.count(word))}
  end

  defp parse_timezone_offset(b) do
    {v, b} = ParseDecimal.from_decimal_charlist(b)

    tz_min = rem(v, 100)
    tz_hour = div(v, 100)

    cover {tz_hour * 60 + tz_min, b}
  end

  @doc ~S"""
  Sanitize the given string for use in an identity and append to output.

  Trims whitespace from both ends and special characters `\n < >` that
  interfere with parsing; appends all other characters to the output.
  """
  @spec sanitized(s :: String.t()) :: String.t()
  def sanitized(s) when is_binary(s) do
    s
    |> String.trim()
    |> String.replace(~r/[<>\x00-\x0C\x0E-\x1F]/, "")
  end

  @doc ~S"""
  Formats a timezone offset.
  """
  @spec format_timezone(offset :: tz_offset()) :: String.t()
  def format_timezone(offset) when is_integer(offset) do
    sign =
      if offset < 0 do
        cover "-"
      else
        cover "+"
      end

    offset =
      if offset < 0 do
        cover -offset
      else
        offset
      end

    offset_hours = div(offset, 60)
    offset_mins = rem(offset, 60)

    hours_prefix =
      if offset_hours < 10 do
        cover "0"
      else
        cover ""
      end

    mins_prefix =
      if offset_mins < 10 do
        cover "0"
      else
        cover ""
      end

    cover "#{sign}#{hours_prefix}#{offset_hours}#{mins_prefix}#{offset_mins}"
  end

  @doc ~S"""
  Returns `true` if the struct is a valid `PersonIdent`.
  """
  @spec valid?(person_ident :: any) :: boolean
  def valid?(person_ident)

  def valid?(%__MODULE__{name: name, email: email, when: whxn, tz_offset: tz_offset})
      when is_binary(name) and is_binary(email) and is_integer(whxn) and is_integer(tz_offset) and
             tz_offset >= -720 and tz_offset <= 840,
      do: cover(true)

  def valid?(_), do: cover(false)

  @doc ~S"""
  Formats the person identity for git storage.
  """
  @spec to_external_string(person_ident :: t) :: String.t()
  def to_external_string(person_ident)

  def to_external_string(%__MODULE__{name: name, email: email, when: whxn, tz_offset: tz_offset})
      when is_binary(name) and is_binary(email) and is_integer(whxn) and is_integer(tz_offset) do
    cover "#{sanitized(name)} <#{sanitized(email)}> #{div(whxn, 1000)} #{
            format_timezone(tz_offset)
          }"
  end

  defimpl String.Chars do
    defdelegate to_string(person_ident), to: Xgit.PersonIdent, as: :to_external_string
  end
end
