defmodule Plotex do
  alias Plotex.ViewRange
  alias Plotex.Axis
  alias Plotex.Output.Formatter
  require Logger

  @moduledoc """
  Documentation for Plotex.

  TODO
  """
  defstruct [:config, :xticks, :yticks, :datasets]

  @type data_types :: number | DateTime.t() | NaiveDateTime.t()
  @type data_item :: Stream.t(data_types) | Enum.t(data_types)
  @type data_pair :: {data_item, data_item}
  @type data :: Stream.t(data_pair) | Enum.t(data_pair)

  @type t :: %Plotex{
          config: Plotex.Config.t(),
          xticks: Enumerable.t(),
          yticks: Enumerable.t(),
          datasets: Enumerable.t()
        }

  @doc """
  Generates a stream of the data points (ticks) for a given axis.
  """
  def generate_axis(%Axis{units: units} = axis) do
    unless axis.limits.start == nil || axis.limits.stop == nil do
      %{data: data, basis: basis} = Plotex.Axis.Units.scale(units, axis.limits)
      # Logger.warn("TIME generate_axis: #{inspect(data |> Enum.to_list)}")
      # Logger.warn("TIME generate_axis: limits: #{inspect(axis.limits)}")
      # Logger.warn("TIME generate_axis: view: #{inspect(axis.view)}")
      trng = scale_data(data, axis)

      # Logger.warn("TIME generate_axis: trng: #{inspect(trng |> Enum.to_list)}")
      # Logger.warn("TIME generate_axis: range: #{inspect(axis.view)}")

      ticks =
        Stream.zip(data, trng)
        # |> Stream.each(& Logger.warn("dt gen view: #{inspect &1}"))
        |> Stream.filter(&(elem(&1, 1) >= axis.view.start))
        |> Stream.filter(&(elem(&1, 1) <= axis.view.stop))
        |> Enum.to_list()

      [data: ticks, basis: basis]
    else
      [data: [], basis: nil]
    end
  end

  @doc """
  Returns a stream of scaled data points zipped with the original points.
  """
  def scale_data(_data, %Axis{limits: %{start: start, stop: stop}} = _axis)
      when is_nil(start) or is_nil(stop),
      do: []

  def scale_data(data, %Axis{} = axis) do
    # Logger.warn("SCALE_DATA: #{inspect axis}")
    m =
      ViewRange.diff(axis.view.stop, axis.view.start) /
        ViewRange.diff(axis.limits.stop, axis.limits.start)

    b = axis.view.start |> ViewRange.to_val()
    x! = axis.limits.start |> ViewRange.to_val()

    data
    |> Stream.map(fn x -> m * (ViewRange.to_val(x) - x!) + b end)
  end

  @doc """
  Returns of scaled data for both X & Y coordinates for a given {X,Y} dataset.
  """
  def plot_data({xdata, ydata}, %Axis{} = xaxis, %Axis{} = yaxis) do
    xrng = scale_data(xdata, xaxis)
    yrng = scale_data(ydata, yaxis)

    {Enum.zip(xdata, xrng), Enum.zip(ydata, yrng)}
  end

  @doc """
  Find the appropriate limits given an enumerable of datasets.

  For example, given {[1,2,3,4], [0.4,0.3,0.2,0.1]} will find the X limits 1..4
  and the Y limits of 0.1..0.4.
  """
  def limits(datasets, opts \\ []) do
    # Logger.warn("LIMITS: #{inspect opts} ")
    proj = Keyword.get(opts, :projection, :cartesian)
    min_xrange = get_in(opts, [:xaxis, :view_min]) || ViewRange.empty()
    min_yrange = get_in(opts, [:yaxis, :view_min]) || ViewRange.empty()

    {xl, yl} =
      for {xdata, ydata} <- datasets, reduce: {min_xrange, min_yrange} do
        {xlims, ylims} ->
          xlims! = xdata |> ViewRange.from(proj)
          ylims! = ydata |> ViewRange.from(proj)

          xlims! = ViewRange.min_max(xlims, xlims!)
          ylims! = ViewRange.min_max(ylims, ylims!)

          {xlims!, ylims!}
      end

    xl = ViewRange.pad(xl, opts[:xaxis] || [])
    yl = ViewRange.pad(yl, opts[:yaxis] || [])

    # Logger.warn("lims reduced: limits!: post!: #{inspect {xl, yl}}")
    {xl, yl}
  end

  def std_units(opts) do
    case opts[:kind] do
      nil -> nil
      :numeric -> %Axis.Units.Numeric{}
      :datetime -> %Axis.Units.Time{}
    end
  end

  def std_fmt(opts) do
    case opts[:kind] do
      nil -> %Plotex.Output.Formatter.NumericDefault{}
      :numeric -> %Plotex.Output.Formatter.NumericDefault{}
      :datetime -> %Plotex.Output.Formatter.Calendar{}
    end
  end

  @doc """
  Create a Plotex struct for given datasets and configuration. Will load and scan data
  for all input datasets.
  """
  @spec plot(Plotex.data(), Keyword.t()) :: Plotex.t()
  def plot(datasets, opts \\ []) do
    {xlim, ylim} = limits(datasets, opts)

    # ticks = opts[:xaxis][:ticks]

    # And this part is kludgy...
    xaxis = %Axis{
      limits: xlim,
      units: struct(opts[:xaxis][:units] || std_units(opts[:xaxis]) || %Axis.Units.Numeric{}),
      formatter:
        struct(opts[:xaxis][:formatter] || std_fmt(opts[:xaxis]) || %Formatter.NumericDefault{}),
      view: %ViewRange{start: 10, stop: (opts[:xaxis][:width] || 100) - 10}
    }

    yaxis = %Axis{
      limits: ylim,
      units: struct(opts[:yaxis][:units] || std_units(opts[:yaxis]) || %Axis.Units.Numeric{}),
      formatter:
        struct(
          opts[:yaxis][:formatter] || std_fmt(opts[:yaxis]) || %Formatter.Calendar{}
        ),
      view: %ViewRange{start: 10, stop: (opts[:yaxis][:width] || 100) - 10}
    }

    [data: xticks, basis: xbasis] = generate_axis(xaxis)

    [data: yticks, basis: ybasis] = generate_axis(yaxis)

    xaxis = xaxis |> Map.put(:basis, xbasis)
    yaxis = yaxis |> Map.put(:basis, ybasis)

    # Logger.warn("plot xaxis: #{inspect xaxis}")
    # Logger.warn("plot yaxis: #{inspect yaxis}")

    config = %Plotex.Config{
      xaxis: xaxis,
      yaxis: yaxis
    }

    # Logger.warn("xticks: #{inspect xticks  |> Enum.to_list()}")
    # Logger.warn("yticks: #{inspect yticks  |> Enum.to_list()}")

    datasets! =
      for {data, idx} <- datasets |> Stream.with_index(), into: [] do
        {xd, yd} = Plotex.plot_data(data, config.xaxis, config.yaxis)
        {Stream.zip(xd, yd), idx}
      end

    # Logger.warn  "datasets! => #{inspect datasets! |> Enum.at(0) |> elem(0) |> Enum.to_list()}"

    %Plotex{config: config, xticks: xticks, yticks: yticks, datasets: datasets!}
  end
end
