defmodule TeslaMate.Log do
  @moduledoc """
  The Log context.
  """

  require Logger

  import TeslaMate.CustomExpressions
  import Ecto.Query, warn: false

  alias __MODULE__.{Car, Drive, Update, ChargingProcess, Charge, Position, State}
  alias TeslaMate.{Repo, Locations, Settings}
  alias TeslaMate.Locations.GeoFence
  alias TeslaMate.Settings.{CarSettings, GlobalSettings}

  ## Car

  def list_cars do
    Repo.all(Car)
  end

  def get_car!(id) do
    Repo.get!(Car, id)
  end

  def get_car_by([{_key, nil}]), do: nil
  def get_car_by([{_key, _val}] = opts), do: Repo.get_by(Car, opts)

  def create_car(attrs) do
    %Car{settings: %CarSettings{}}
    |> Car.changeset(attrs)
    |> Repo.insert()
  end

  def create_or_update_car(%Ecto.Changeset{} = changeset) do
    with {:ok, car} <- Repo.insert_or_update(changeset) do
      {:ok, Repo.preload(car, [:settings])}
    end
  end

  def update_car(%Car{} = car, attrs, opts \\ []) do
    with {:ok, car} <- car |> Car.changeset(attrs) |> Repo.update() do
      preloads = Keyword.get(opts, :preload, [])
      {:ok, Repo.preload(car, preloads, force: true)}
    end
  end

  def recalculate_efficiencies(%GlobalSettings{} = settings) do
    for car <- list_cars() do
      {:ok, _car} = recalculate_efficiency(car, settings)
    end

    :ok
  end

  ## State

  def start_state(%Car{} = car, state, opts \\ []) when not is_nil(state) do
    now = Keyword.get(opts, :date) || DateTime.utc_now()

    case get_current_state(car) do
      %State{state: ^state} = s ->
        {:ok, s}

      %State{} = s ->
        Repo.transaction(fn ->
          with {:ok, _} <- s |> State.changeset(%{end_date: now}) |> Repo.update(),
               {:ok, new_state} <- create_state(car, %{state: state, start_date: now}) do
            new_state
          else
            {:error, reason} -> Repo.rollback(reason)
          end
        end)

      nil ->
        create_state(car, %{state: state, start_date: now})
    end
  end

  def get_current_state(%Car{id: id}) do
    State
    |> where([s], ^id == s.car_id and is_nil(s.end_date))
    |> Repo.one()
  end

  def create_current_state(%Car{id: id} = car) do
    query =
      from s in State,
        where: s.car_id == ^id,
        order_by: [desc: s.start_date],
        limit: 1

    with nil <- get_current_state(car),
         %State{} = state <- Repo.one(query),
         {:ok, _} <- state |> State.changeset(%{end_date: nil}) |> Repo.update() do
      :ok
    else
      {:error, reason} -> {:error, reason}
      _ -> :ok
    end
  end

  def complete_current_state(%Car{id: id} = car) do
    case get_current_state(car) do
      %State{start_date: date} = state ->
        query =
          from s in State,
            where: s.car_id == ^id and s.start_date > ^date,
            order_by: [asc: s.start_date],
            limit: 1

        end_date =
          case Repo.one(query) do
            %State{start_date: d} -> d
            nil -> DateTime.add(date, 1, :second)
          end

        with {:ok, _} <-
               state
               |> State.changeset(%{end_date: end_date})
               |> Repo.update() do
          :ok
        end

      nil ->
        :ok
    end
  end

  def get_earliest_state(%Car{id: id}) do
    State
    |> where(car_id: ^id)
    |> order_by(asc: :start_date)
    |> limit(1)
    |> Repo.one()
  end

  defp create_state(%Car{id: id}, attrs) do
    %State{car_id: id}
    |> State.changeset(attrs)
    |> Repo.insert()
  end

  ## Position

  def insert_position(%Drive{id: id, car_id: car_id}, attrs) do
    %Position{car_id: car_id, drive_id: id}
    |> Position.changeset(attrs)
    |> Repo.insert()
  end

  def insert_position(%Car{id: id}, attrs) do
    %Position{car_id: id}
    |> Position.changeset(attrs)
    |> Repo.insert()
  end

  def get_latest_position do
    Position
    |> order_by(desc: :date)
    |> limit(1)
    |> Repo.one()
  end

  def get_latest_position(%Car{id: id}) do
    Position
    |> where(car_id: ^id)
    |> order_by(desc: :date)
    |> limit(1)
    |> Repo.one()
  end

  def get_positions_without_elevation(min_id \\ 0, opts \\ []) do
    limit = Keyword.get(opts, :limit, 100)

    date_earliest =
      cond do
        min_id == 0 ->
          DateTime.add(DateTime.utc_now(), -10, :day)

        true ->
          {:ok, default_date_earliest, _} = DateTime.from_iso8601("2003-07-01T00:00:00Z")
          default_date_earliest
      end

    naive_date_earliest = DateTime.to_naive(date_earliest)

    non_streamed_drives =
      Repo.all(
        from(d in Drive,
          as: :d,
          where:
            d.start_date > ^naive_date_earliest and
              exists(
                from(p in Position,
                  where: p.drive_id == parent_as(:d).id and p.id > ^min_id
                )
              ) and
              not exists(
                from(p in Position,
                  where:
                    p.drive_id == parent_as(:d).id and p.id > ^min_id and
                      not is_nil(p.odometer) and is_nil(p.ideal_battery_range_km)
                )
              ),
          select: d.id
        )
      )

    Position
    |> where(
      [p],
      p.id > ^min_id and is_nil(p.elevation) and p.drive_id in ^non_streamed_drives and
        p.date > ^naive_date_earliest
    )
    |> order_by(asc: :id)
    |> limit(^limit)
    |> Repo.all()
    |> Enum.reverse()
    |> case do
      [%Position{id: next} | _] = positions ->
        {Enum.reverse(positions), next}

      [] ->
        {[], nil}
    end
  end

  def update_position(%Position{} = position, attrs) do
    position
    |> Position.changeset(attrs)
    |> Repo.update()
  end

  ## Drive

  def start_drive(%Car{id: id}) do
    %Drive{car_id: id}
    |> Drive.changeset(%{start_date: DateTime.utc_now()})
    |> Repo.insert()
  end

  def close_drive(%Drive{id: id} = drive, opts \\ []) do
    drive = Repo.preload(drive, [:car])

    drive_data =
      from p in Position,
        select: %{
          count: count() |> over(:w),
          start_position_id: first_value(p.id) |> over(:w),
          end_position_id: last_value(p.id) |> over(:w),
          outside_temp_avg: avg(p.outside_temp) |> over(:w),
          inside_temp_avg: avg(p.inside_temp) |> over(:w),
          speed_max: max(p.speed) |> over(:w),
          power_max: max(p.power) |> over(:w),
          power_min: min(p.power) |> over(:w),
          start_date: first_value(p.date) |> over(:w),
          end_date: last_value(p.date) |> over(:w),
          start_km: first_value(p.odometer) |> over(:w),
          end_km: last_value(p.odometer) |> over(:w),
          distance: (last_value(p.odometer) |> over(:w)) - (first_value(p.odometer) |> over(:w)),
          duration_min:
            fragment(
              "round(extract(epoch from (? - ?)) / 60)::integer",
              last_value(p.date) |> over(:w),
              first_value(p.date) |> over(:w)
            ),
          start_ideal_range_km: -1,
          end_ideal_range_km: -1,
          start_rated_range_km: -1,
          end_rated_range_km: -1,
          ascent: 0,
          descent: 0
        },
        windows: [
          w: [
            order_by:
              fragment("? RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING", p.date)
          ]
        ],
        where: p.drive_id == ^id,
        limit: 1

    non_streamed_drive_data =
      from p in Position,
        select: %{
          start_ideal_range_km: first_value(p.ideal_battery_range_km) |> over(:w),
          end_ideal_range_km: last_value(p.ideal_battery_range_km) |> over(:w),
          start_rated_range_km: first_value(p.rated_battery_range_km) |> over(:w),
          end_rated_range_km: last_value(p.rated_battery_range_km) |> over(:w)
        },
        windows: [
          w: [
            order_by:
              fragment("? RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING", p.date)
          ]
        ],
        where:
          p.drive_id == ^id and
            not is_nil(p.ideal_battery_range_km) and
            not is_nil(p.odometer),
        limit: 1

    # If the sum of elevation gains exceeds the max value of a smallint (32767), set it to 0.
    # If the sum of elevation losses exceeds the max value of a smallint (32767), set it to 0.
    elevation_data =
      from p1 in subquery(
             from p in Position,
               where: p.drive_id == ^id and not is_nil(p.elevation),
               select: %{
                 elevation_diff: p.elevation - (lag(p.elevation) |> over(order_by: [asc: p.date]))
               }
           ),
           select: %{
             elevation_gains:
               fragment(
                 "COALESCE(NULLIF(LEAST(SUM(CASE WHEN ? > 0 THEN ? ELSE 0 END), 32768), 32768), 0)",
                 p1.elevation_diff,
                 p1.elevation_diff
               ),
             elevation_losses:
               fragment(
                 "COALESCE(NULLIF(LEAST(SUM(CASE WHEN ? < 0 THEN ABS(?) ELSE 0 END), 32768), 32768), 0)",
                 p1.elevation_diff,
                 p1.elevation_diff
               )
           }

    query =
      from d0 in subquery(drive_data),
        join: d1 in subquery(non_streamed_drive_data),
        on: true,
        join: e in subquery(elevation_data),
        on: true,
        select: %{
          d0
          | start_ideal_range_km: d1.start_ideal_range_km,
            end_ideal_range_km: d1.end_ideal_range_km,
            start_rated_range_km: d1.start_rated_range_km,
            end_rated_range_km: d1.end_rated_range_km,
            ascent: e.elevation_gains,
            descent: e.elevation_losses
        }

    case Repo.one(query) do
      %{count: count, distance: distance} = attrs when count >= 2 and distance >= 0.01 ->
        lookup_address = Keyword.get(opts, :lookup_address, true)

        start_pos = Repo.get!(Position, attrs.start_position_id)
        end_pos = Repo.get!(Position, attrs.end_position_id)

        attrs =
          if lookup_address do
            attrs
            |> put_address(:start_address_id, start_pos)
            |> put_address(:end_address_id, end_pos)
          else
            attrs
          end

        attrs =
          attrs
          |> put_geofence(:start_geofence_id, start_pos)
          |> put_geofence(:end_geofence_id, end_pos)

        drive
        |> Drive.changeset(attrs)
        |> Repo.update()

      _ ->
        drive
        |> Drive.changeset(%{distance: 0, duration_min: 0})
        |> Repo.delete()
    end
  end

  defp put_address(attrs, key, position) do
    case Locations.find_address(position) do
      {:ok, %Locations.Address{id: id}} ->
        Map.put(attrs, key, id)

      {:error, reason} ->
        Logger.warning("Address not found: #{inspect(reason)}")
        attrs
    end
  end

  defp put_geofence(attrs, key, position) do
    case Locations.find_geofence(position) do
      %GeoFence{id: id} -> Map.put(attrs, key, id)
      nil -> attrs
    end
  end

  ## ChargingProcess

  def get_charging_process!(id) do
    ChargingProcess
    |> where(id: ^id)
    |> preload([:address, :geofence, :car, :position])
    |> Repo.one!()
  end

  def update_charging_process(%ChargingProcess{} = charge, attrs) do
    charge
    |> ChargingProcess.changeset(attrs)
    |> Repo.update()
  end

  def start_charging_process(%Car{id: id}, %{latitude: _, longitude: _} = attrs, opts \\ []) do
    lookup_address = Keyword.get(opts, :lookup_address, true)
    position = Map.put(attrs, :car_id, id)

    address_id =
      if lookup_address do
        case Locations.find_address(position) do
          {:ok, %Locations.Address{id: id}} ->
            id

          {:error, reason} ->
            Logger.warning("Address not found: #{inspect(reason)}")
            nil
        end
      end

    geofence_id =
      with %GeoFence{id: id} <- Locations.find_geofence(position) do
        id
      end

    with {:ok, cproc} <-
           %ChargingProcess{car_id: id, address_id: address_id, geofence_id: geofence_id}
           |> ChargingProcess.changeset(%{start_date: DateTime.utc_now(), position: position})
           |> Repo.insert() do
      {:ok, Repo.preload(cproc, [:address, :geofence])}
    end
  end

  def insert_charge(%ChargingProcess{id: id}, attrs) do
    %Charge{charging_process_id: id}
    |> Charge.changeset(attrs)
    |> Repo.insert()
  end

  def complete_charging_process(%ChargingProcess{} = charging_process) do
    charging_process = Repo.preload(charging_process, [{:car, :settings}, :geofence])
    settings = Settings.get_global_settings!()

    type =
      from(c in Charge,
        select: %{
          fast_charger_type: fragment("mode() WITHIN GROUP (ORDER BY ?)", c.fast_charger_type)
        },
        where: c.charging_process_id == ^charging_process.id and c.charger_power > 0
      )

    stats =
      from(c in Charge,
        join: t in subquery(type),
        on: true,
        select: %{
          start_date: first_value(c.date) |> over(:w),
          end_date: last_value(c.date) |> over(:w),
          start_ideal_range_km: first_value(c.ideal_battery_range_km) |> over(:w),
          end_ideal_range_km: last_value(c.ideal_battery_range_km) |> over(:w),
          start_rated_range_km: first_value(c.rated_battery_range_km) |> over(:w),
          end_rated_range_km: last_value(c.rated_battery_range_km) |> over(:w),
          start_battery_level: first_value(c.battery_level) |> over(:w),
          end_battery_level: last_value(c.battery_level) |> over(:w),
          outside_temp_avg: avg(c.outside_temp) |> over(:w),
          charge_energy_added:
            coalesce(
              nullif(last_value(c.charge_energy_added) |> over(:w), 0),
              max(c.charge_energy_added) |> over(:w)
            ) -
              (first_value(c.charge_energy_added) |> over(:w)),
          duration_min:
            duration_min(last_value(c.date) |> over(:w), first_value(c.date) |> over(:w)),
          fast_charger_type: t.fast_charger_type
        },
        windows: [
          w: [
            order_by:
              fragment("? RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING", c.date)
          ]
        ],
        where: [charging_process_id: ^charging_process.id],
        limit: 1
      )
      |> Repo.one() || %{end_date: DateTime.utc_now(), charge_energy_added: nil}

    charge_energy_used = calculate_energy_used(charging_process)

    attrs =
      stats
      |> Map.put(:charge_energy_used, charge_energy_used)
      |> Map.update(:charge_energy_added, nil, fn kwh ->
        cond do
          kwh == nil or Decimal.negative?(kwh) -> nil
          true -> kwh
        end
      end)
      |> put_cost(charging_process)

    with {:ok, cproc} <- charging_process |> ChargingProcess.changeset(attrs) |> Repo.update(),
         {:ok, _car} <- recalculate_efficiency(charging_process.car, settings) do
      {:ok, cproc}
    end
  end

  def update_energy_used(%ChargingProcess{} = charging_process) do
    charging_process
    |> ChargingProcess.changeset(%{charge_energy_used: calculate_energy_used(charging_process)})
    |> Repo.update()
  end

  defp calculate_energy_used(%ChargingProcess{id: id} = charging_process) do
    phases = determine_phases(charging_process)

    query =
      from c in Charge,
        select: %{
          energy_used:
            c_if is_nil(c.charger_phases) do
              c.charger_power
            else
              c.charger_actual_current * c.charger_voltage * type(^phases, :float) / 1000.0
            end *
              fragment(
                "EXTRACT(epoch FROM (?))",
                c.date - (lag(c.date) |> over(order_by: c.date))
              ) / 3600
        },
        where: c.charging_process_id == ^id

    Repo.one(
      from e in subquery(query),
        select: sum(e.energy_used) |> type(:decimal),
        where: e.energy_used >= 0
    )
  end

  defp determine_phases(%ChargingProcess{id: id, car_id: car_id}) do
    from(c in Charge,
      select: {
        avg(c.charger_power * 1000.0 / nullif(c.charger_actual_current * c.charger_voltage, 0))
        |> type(:float),
        avg(c.charger_phases) |> type(:integer),
        avg(c.charger_voltage) |> type(:float),
        count()
      },
      group_by: c.charging_process_id,
      where: c.charging_process_id == ^id
    )
    |> Repo.one()
    |> case do
      {p, r, v, n} when not is_nil(p) and p > 0 and n > 15 ->
        cond do
          r == round(p) ->
            r

          r == 3 and abs(p / :math.sqrt(r) - 1) <= 0.1 ->
            Logger.info("Voltage correction: #{round(v)}V -> #{round(v / :math.sqrt(r))}V",
              car_id: car_id
            )

            :math.sqrt(r)

          abs(round(p) - p) <= 0.3 ->
            Logger.info("Phase correction: #{r} -> #{round(p)}", car_id: car_id)
            round(p)

          true ->
            nil
        end

      _ ->
        nil
    end
  end

  defp put_cost(stats, %ChargingProcess{} = charging_process) do
    alias ChargingProcess, as: CP

    cost =
      case {stats, charging_process} do
        {%{fast_charger_type: "Tesla" <> _},
         %CP{car: %Car{settings: %CarSettings{free_supercharging: true}}}} ->
          0.0

        {%{charge_energy_used: kwh_used, charge_energy_added: kwh_added},
         %CP{
           geofence: %GeoFence{
             billing_type: :per_kwh,
             cost_per_unit: cost_per_kwh,
             session_fee: session_fee
           }
         }} ->
          if match?(%Decimal{}, kwh_used) or match?(%Decimal{}, kwh_added) do
            cost =
              with %Decimal{} <- cost_per_kwh do
                [kwh_added, kwh_used]
                |> Enum.reject(&is_nil/1)
                |> Enum.max(Decimal)
                |> Decimal.mult(cost_per_kwh)
              end

            if match?(%Decimal{}, cost) or match?(%Decimal{}, session_fee) do
              Decimal.add(session_fee || 0, cost || 0)
            end
          end

        {%{duration_min: minutes},
         %CP{
           geofence: %GeoFence{
             billing_type: :per_minute,
             cost_per_unit: cost_per_minute,
             session_fee: session_fee
           }
         }}
        when is_number(minutes) ->
          cost = Decimal.mult(minutes, cost_per_minute)
          Decimal.add(session_fee || 0, cost)

        {_, _} ->
          nil
      end

    Map.put(stats, :cost, cost)
  end

  defp recalculate_efficiency(car, settings, opts \\ [{5, 8}, {4, 5}, {3, 3}, {2, 2}])
  defp recalculate_efficiency(car, _settings, []), do: {:ok, car}

  defp recalculate_efficiency(%Car{id: id} = car, settings, [{precision, threshold} | opts]) do
    {start_range, end_range} =
      case settings do
        %GlobalSettings{preferred_range: :ideal} ->
          {:start_ideal_range_km, :end_ideal_range_km}

        %GlobalSettings{preferred_range: :rated} ->
          {:start_rated_range_km, :end_rated_range_km}
      end

    query =
      from c in ChargingProcess,
        select: {
          round(
            c.charge_energy_added / nullif(field(c, ^end_range) - field(c, ^start_range), 0),
            ^precision
          ),
          count()
        },
        where:
          c.car_id == ^id and c.duration_min > 10 and c.end_battery_level <= 95 and
            not is_nil(field(c, ^end_range)) and not is_nil(field(c, ^start_range)) and
            c.charge_energy_added > 0.0,
        group_by: 1,
        order_by: [desc: 2],
        limit: 1

    case Repo.one(query) do
      {factor, n} when n >= threshold and not is_nil(factor) and factor > 0 ->
        Logger.info("Derived efficiency factor: #{factor * 1000} Wh/km (#{n}x confirmed)",
          car_id: id
        )

        car
        |> Car.changeset(%{efficiency: factor})
        |> Repo.update()

      _ ->
        recalculate_efficiency(car, settings, opts)
    end
  end

  ## Update

  def start_update(%Car{id: id}, opts \\ []) do
    start_date = Keyword.get(opts, :date) || DateTime.utc_now()

    %Update{car_id: id}
    |> Update.changeset(%{start_date: start_date})
    |> Repo.insert()
  end

  def cancel_update(%Update{} = update) do
    Repo.delete(update)
  end

  def finish_update(%Update{} = update, version, opts \\ []) do
    end_date = Keyword.get(opts, :date) || DateTime.utc_now()

    update
    |> Update.changeset(%{end_date: end_date, version: version})
    |> Repo.update()
  end

  def get_latest_update(%Car{id: id}) do
    from(u in Update, where: [car_id: ^id], order_by: [desc: :start_date], limit: 1)
    |> Repo.one()
  end

  def insert_missed_update(%Car{id: id}, version, opts \\ []) do
    date = Keyword.get(opts, :date) || DateTime.utc_now()

    %Update{car_id: id}
    |> Update.changeset(%{start_date: date, end_date: date, version: version})
    |> Repo.insert()
  end
end
