defmodule Berrypod.Shipping do
  @moduledoc """
  The Shipping context.

  Manages cached shipping rates from POD providers and calculates
  shipping estimates for cart and checkout.
  """

  import Ecto.Query
  alias Berrypod.ExchangeRate
  alias Berrypod.Repo
  alias Berrypod.Shipping.ShippingRate
  alias Berrypod.Products
  alias Berrypod.Settings

  require Logger

  @country_names %{
    "AT" => "Austria",
    "AU" => "Australia",
    "BE" => "Belgium",
    "BG" => "Bulgaria",
    "CA" => "Canada",
    "CH" => "Switzerland",
    "CY" => "Cyprus",
    "CZ" => "Czechia",
    "DE" => "Germany",
    "DK" => "Denmark",
    "EE" => "Estonia",
    "ES" => "Spain",
    "FI" => "Finland",
    "FR" => "France",
    "GB" => "United Kingdom",
    "GR" => "Greece",
    "HR" => "Croatia",
    "HU" => "Hungary",
    "IE" => "Ireland",
    "IT" => "Italy",
    "JP" => "Japan",
    "LT" => "Lithuania",
    "LU" => "Luxembourg",
    "LV" => "Latvia",
    "MT" => "Malta",
    "NL" => "Netherlands",
    "NO" => "Norway",
    "NZ" => "New Zealand",
    "PL" => "Poland",
    "PT" => "Portugal",
    "RO" => "Romania",
    "SE" => "Sweden",
    "SI" => "Slovenia",
    "SK" => "Slovakia",
    "US" => "United States"
  }

  # =============================================================================
  # Rate Storage
  # =============================================================================

  @default_buffer_percent 5

  @doc """
  Bulk upserts shipping rates for a provider connection.

  When `exchange_rates` are provided (e.g. `%{"USD" => 0.79}`), rates are
  converted to GBP at insert time with a buffer (default #{@default_buffer_percent}%).
  This locks in the exchange rate at sync time rather than at display time.

  Existing rates for the same (connection, blueprint, provider, country) combo
  are replaced. Returns the number of upserted rows.
  """
  def upsert_rates(provider_connection_id, rates, exchange_rates \\ nil)
      when is_list(rates) do
    now = DateTime.utc_now() |> DateTime.truncate(:second)
    buffer = get_buffer_percent()

    entries =
      Enum.map(rates, fn rate ->
        {first, additional, currency} =
          convert_rate_to_gbp(rate, exchange_rates, buffer)

        %{
          id: Ecto.UUID.generate(),
          provider_connection_id: provider_connection_id,
          blueprint_id: rate.blueprint_id,
          print_provider_id: rate.print_provider_id,
          country_code: rate.country_code,
          first_item_cost: first,
          additional_item_cost: additional,
          currency: currency,
          handling_time_days: rate[:handling_time_days],
          inserted_at: now,
          updated_at: now
        }
      end)

    {count, _} =
      Repo.insert_all(ShippingRate, entries,
        on_conflict:
          {:replace,
           [
             :first_item_cost,
             :additional_item_cost,
             :currency,
             :handling_time_days,
             :updated_at
           ]},
        conflict_target: [
          :provider_connection_id,
          :blueprint_id,
          :print_provider_id,
          :country_code
        ]
      )

    Logger.info("Upserted #{count} shipping rates for connection #{provider_connection_id}")
    Berrypod.Settings.SettingsCache.invalidate_cached(:countries_with_names)
    {:ok, count}
  end

  @doc """
  Gets a single shipping rate for a blueprint/provider/country combo.

  Falls back to `REST_OF_THE_WORLD` if no exact country match exists
  (Printify uses this as a catch-all for unlisted countries).
  """
  def get_rate(blueprint_id, print_provider_id, country_code) do
    ShippingRate
    |> where(
      [r],
      r.blueprint_id == ^blueprint_id and
        r.print_provider_id == ^print_provider_id and
        r.country_code == ^country_code
    )
    |> limit(1)
    |> Repo.one()
    |> case do
      nil when country_code != "REST_OF_THE_WORLD" ->
        get_rate(blueprint_id, print_provider_id, "REST_OF_THE_WORLD")

      result ->
        result
    end
  end

  # =============================================================================
  # Cart Calculation
  # =============================================================================

  @doc """
  Calculates the shipping estimate for a cart.

  Takes a list of hydrated cart items (with variant_id, quantity) and a
  country code. Groups items by print provider, looks up rates, and
  returns the total in the shop's currency (pence).

  Returns `{:ok, cost_pence}` or `{:error, :rates_not_found}`.
  """
  def calculate_for_cart([], _country_code), do: {:ok, 0}

  def calculate_for_cart(cart_items, country_code) do
    variant_ids = Enum.map(cart_items, & &1.variant_id)
    variants_map = Products.get_variants_with_products(variant_ids)

    # Build list of {print_provider_id, blueprint_id, quantity, variant_id}
    items_with_provider =
      Enum.flat_map(cart_items, fn item ->
        case Map.get(variants_map, item.variant_id) do
          nil ->
            []

          variant ->
            provider_data = variant.product.provider_data || %{}
            blueprint_id = provider_data["blueprint_id"]
            print_provider_id = provider_data["print_provider_id"]

            if blueprint_id && print_provider_id do
              [
                %{
                  print_provider_id: print_provider_id,
                  blueprint_id: blueprint_id,
                  quantity: item.quantity
                }
              ]
            else
              []
            end
        end
      end)

    if Enum.empty?(items_with_provider) do
      {:error, :rates_not_found}
    else
      calculate_grouped(items_with_provider, country_code)
    end
  end

  defp calculate_grouped(items, country_code) do
    # Group by print_provider_id — items from same provider ship together
    groups = Enum.group_by(items, & &1.print_provider_id)

    results =
      Enum.map(groups, fn {_provider_id, group_items} ->
        calculate_provider_group(group_items, country_code)
      end)

    case Enum.find(results, &match?({:error, _}, &1)) do
      nil ->
        total = results |> Enum.map(fn {:ok, cost} -> cost end) |> Enum.sum()
        {:ok, total}

      error ->
        error
    end
  end

  defp calculate_provider_group(group_items, country_code) do
    # Look up rates for each unique blueprint in the group
    rates =
      group_items
      |> Enum.uniq_by(& &1.blueprint_id)
      |> Enum.map(fn item ->
        rate = get_rate(item.blueprint_id, item.print_provider_id, country_code)
        {item, rate}
      end)

    if Enum.any?(rates, fn {_item, rate} -> is_nil(rate) end) do
      {:error, :rates_not_found}
    else
      # Take the highest first_item_cost across all blueprints in the group
      {_best_item, best_rate} = Enum.max_by(rates, fn {_item, rate} -> rate.first_item_cost end)

      total_qty = Enum.reduce(group_items, 0, fn item, acc -> acc + item.quantity end)

      # Build a map of blueprint_id => rate for additional item costs
      rate_by_blueprint = Map.new(rates, fn {item, rate} -> {item.blueprint_id, rate} end)

      # First item uses the highest first_item_cost
      # Remaining items each use their own blueprint's additional_item_cost
      additional_cost =
        group_items
        |> Enum.flat_map(fn item ->
          rate = Map.get(rate_by_blueprint, item.blueprint_id)
          List.duplicate(rate.additional_item_cost, item.quantity)
        end)
        |> Enum.sort(:desc)
        # Drop the first item (covered by first_item_cost)
        |> Enum.drop(1)
        |> Enum.sum()

      cost =
        if total_qty > 0 do
          best_rate.first_item_cost + additional_cost
        else
          0
        end

      # If rates were converted at sync time, currency is GBP and we're done.
      # For legacy unconverted rates, convert now using cached exchange rates.
      gbp_cost =
        if best_rate.currency == "GBP" do
          cost
        else
          ExchangeRate.rate_for(best_rate.currency)
          |> then(&ceil(cost * &1))
        end

      {:ok, gbp_cost}
    end
  end

  # =============================================================================
  # Sync-time Currency Conversion
  # =============================================================================

  # Converts a rate to GBP at sync time using live exchange rates + buffer.
  # Returns {first_item_cost, additional_item_cost, "GBP"}.
  defp convert_rate_to_gbp(rate, exchange_rates, buffer_percent)

  defp convert_rate_to_gbp(rate, _exchange_rates, _buffer)
       when rate.currency in ["GBP", "gbp"] do
    {rate.first_item_cost, rate.additional_item_cost, "GBP"}
  end

  defp convert_rate_to_gbp(rate, exchange_rates, buffer) when is_map(exchange_rates) do
    fx = Map.get(exchange_rates, String.upcase(rate.currency), 0.80)
    multiplier = fx * (1 + buffer / 100)

    {
      ceil(rate.first_item_cost * multiplier),
      ceil(rate.additional_item_cost * multiplier),
      "GBP"
    }
  end

  # No exchange rates provided — store in original currency (legacy path)
  defp convert_rate_to_gbp(rate, nil, _buffer) do
    {rate.first_item_cost, rate.additional_item_cost, rate.currency}
  end

  defp get_buffer_percent do
    case Settings.get_setting("shipping_buffer_percent") do
      nil -> @default_buffer_percent
      val when is_binary(val) -> String.to_integer(val)
      val when is_integer(val) -> val
    end
  end

  # =============================================================================
  # Queries
  # =============================================================================

  @doc """
  Returns true if at least one shipping rate exists.
  """
  def has_shipping_rates? do
    ShippingRate
    |> limit(1)
    |> Repo.exists?()
  end

  @doc """
  Returns a list of distinct country codes that have shipping rates.

  When `REST_OF_THE_WORLD` exists in the DB, all countries from the
  known names map are included (they're covered by the fallback rate).
  """
  def list_available_countries do
    codes =
      ShippingRate
      |> distinct(true)
      |> select([r], r.country_code)
      |> order_by([r], r.country_code)
      |> Repo.all()

    has_rest_of_world = "REST_OF_THE_WORLD" in codes

    codes
    |> Enum.reject(&(&1 == "REST_OF_THE_WORLD"))
    |> then(fn explicit ->
      if has_rest_of_world do
        Map.keys(@country_names) ++ explicit
      else
        explicit
      end
    end)
    |> Enum.uniq()
    |> Enum.sort()
  end

  @doc """
  Returns `{code, name}` tuples for all countries with shipping rates,
  sorted by name.
  """
  def list_available_countries_with_names do
    alias Berrypod.Settings.SettingsCache

    case SettingsCache.get_cached(:countries_with_names) do
      {:ok, countries} ->
        countries

      :miss ->
        countries =
          list_available_countries()
          |> Enum.map(fn code -> {code, country_name(code)} end)
          |> Enum.sort_by(&elem(&1, 1))

        SettingsCache.put_cached(:countries_with_names, countries)
        countries
    end
  end

  @doc """
  Returns the display name for a country code.
  """
  def country_name(code) when is_binary(code) do
    Map.get(@country_names, String.upcase(code), code)
  end
end
