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

  Handles order creation, payment status tracking, fulfilment submission,
  and order retrieval. Payment-provider agnostic — all Stripe-specific
  logic lives in controllers.
  """

  import Ecto.Query
  alias Berrypod.Repo
  alias Berrypod.Orders.{AbandonedCart, EmailSuppression, Order, OrderItem, OrderNotifier}
  alias Berrypod.Products
  alias Berrypod.Providers.Provider

  require Logger

  @doc """
  Lists orders, optionally filtered by payment status.

  ## Options

    * `:status` - filter by payment_status ("paid", "pending", "failed", "refunded")
      Pass nil or "all" to return all orders.

  Returns orders sorted by newest first, with items preloaded.
  """
  def list_orders(opts \\ []) do
    status = opts[:status]

    Order
    |> maybe_filter_status(status)
    |> order_by([o], desc: o.inserted_at)
    |> preload(:items)
    |> Repo.all()
  end

  @doc """
  Like `list_orders/1` but returns a `%Pagination{}` struct.
  """
  def list_orders_paginated(opts \\ []) do
    Order
    |> maybe_filter_status(opts[:status])
    |> order_by([o], desc: o.inserted_at)
    |> preload(:items)
    |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
  end

  defp maybe_filter_status(query, nil), do: query
  defp maybe_filter_status(query, "all"), do: query
  defp maybe_filter_status(query, status), do: where(query, [o], o.payment_status == ^status)

  @doc """
  Returns a map of payment_status => count for all orders.
  """
  def count_orders_by_status do
    Order
    |> group_by(:payment_status)
    |> select([o], {o.payment_status, count(o.id)})
    |> Repo.all()
    |> Map.new()
  end

  @doc """
  Returns true if at least one paid order exists.
  """
  def has_paid_orders? do
    Order
    |> where(payment_status: "paid")
    |> limit(1)
    |> Repo.exists?()
  end

  @doc """
  Returns total revenue (in minor units) from paid orders.
  """
  def total_revenue do
    Order
    |> where(payment_status: "paid")
    |> select([o], sum(o.total))
    |> Repo.one() || 0
  end

  @doc """
  Creates an order with line items from hydrated cart data.

  Expects a map with :items (list of hydrated cart item maps) and optional
  fields like :customer_email. Returns {:ok, order} with items preloaded.
  """
  def create_order(attrs) do
    items = attrs[:items] || []

    subtotal = Enum.reduce(items, 0, fn item, acc -> acc + item.price * item.quantity end)

    order_attrs = %{
      order_number: generate_order_number(),
      subtotal: subtotal,
      total: subtotal,
      currency: Map.get(attrs, :currency, "gbp"),
      customer_email: attrs[:customer_email],
      payment_status: "pending"
    }

    Repo.transaction(fn ->
      case %Order{} |> Order.changeset(order_attrs) |> Repo.insert() do
        {:ok, order} ->
          order_items =
            Enum.map(items, fn item ->
              %{
                order_id: order.id,
                variant_id: item.variant_id,
                product_id: item[:product_id],
                product_name: item.name,
                variant_title: item.variant,
                quantity: item.quantity,
                unit_price: item.price,
                inserted_at: order.inserted_at,
                updated_at: order.updated_at
              }
            end)

          Repo.insert_all(OrderItem, order_items)

          Repo.preload(order, :items)

        {:error, changeset} ->
          Repo.rollback(changeset)
      end
    end)
  end

  @doc """
  Sets the stripe_session_id on an order after creating the Stripe checkout session.
  """
  def set_stripe_session(order, session_id) do
    order
    |> Order.changeset(%{stripe_session_id: session_id})
    |> Repo.update()
  end

  @doc """
  Finds an order by its Stripe checkout session ID.
  """
  def get_order_by_stripe_session(session_id) do
    Order
    |> where([o], o.stripe_session_id == ^session_id)
    |> preload(:items)
    |> Repo.one()
  end

  @doc """
  Marks an order as paid and stores the Stripe payment intent ID.

  Returns {:ok, order} or {:error, :already_paid} if idempotency check fails.
  """
  def mark_paid(order, payment_intent_id) do
    if order.payment_status == "paid" do
      {:ok, order}
    else
      order
      |> Order.changeset(%{
        payment_status: "paid",
        stripe_payment_intent_id: payment_intent_id
      })
      |> Repo.update()
    end
  end

  @doc """
  Marks an order as failed.
  """
  def mark_failed(order) do
    order
    |> Order.changeset(%{payment_status: "failed"})
    |> Repo.update()
  end

  @doc """
  Gets an order by ID with items preloaded.
  """
  def get_order(id) do
    Order
    |> preload(:items)
    |> Repo.get(id)
  end

  @doc """
  Updates an order with the given attributes.
  """
  def update_order(order, attrs) do
    order
    |> Order.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Generates a human-readable order number.

  Format: SS-YYMMDD-XXXX where XXXX is a random alphanumeric string.
  """
  def generate_order_number do
    date = Date.utc_today() |> Calendar.strftime("%y%m%d")
    random = :crypto.strong_rand_bytes(2) |> Base.encode16()
    "SS-#{date}-#{random}"
  end

  # =============================================================================
  # Fulfilment
  # =============================================================================

  @doc """
  Submits an order to the fulfilment provider.

  Looks up product variant data from order items, builds the provider payload,
  and calls the provider's submit_order callback. Idempotent — returns {:ok, order}
  if already submitted.
  """
  def submit_to_provider(%Order{provider_order_id: pid} = order) when not is_nil(pid) do
    {:ok, order}
  end

  def submit_to_provider(%Order{} = order) do
    order = Repo.preload(order, :items)

    with {:ok, conn} <- get_provider_connection_for_order(order),
         {:ok, provider} <- Provider.for_connection(conn),
         {:ok, enriched_items} <- enrich_items(order.items),
         order_data <- build_submission_data(order, enriched_items),
         {:ok, %{provider_order_id: pid}} <- provider.submit_order(conn, order_data) do
      update_fulfilment(order, %{
        fulfilment_status: "submitted",
        provider_order_id: pid,
        fulfilment_error: nil,
        submitted_at: DateTime.utc_now() |> DateTime.truncate(:second)
      })
    else
      {:error, reason} ->
        error_msg = format_submission_error(reason)

        update_fulfilment(order, %{
          fulfilment_status: "failed",
          fulfilment_error: error_msg
        })

        {:error, reason}
    end
  end

  @doc """
  Polls the provider for the current fulfilment status of an order.
  Updates tracking info and timestamps on status transitions.
  """
  def refresh_fulfilment_status(%Order{provider_order_id: nil} = order), do: {:ok, order}

  def refresh_fulfilment_status(%Order{} = order) do
    order = Repo.preload(order, :items)

    with {:ok, conn} <- get_provider_connection_for_order(order),
         {:ok, provider} <- Provider.for_connection(conn),
         {:ok, status_data} <- provider.get_order_status(conn, order.provider_order_id) do
      attrs =
        %{
          fulfilment_status: status_data.status,
          provider_status: status_data.provider_status,
          tracking_number: status_data.tracking_number,
          tracking_url: status_data.tracking_url
        }
        |> maybe_set_timestamp(order)

      with {:ok, updated_order} <- update_fulfilment(order, attrs) do
        if attrs[:fulfilment_status] == "shipped" and order.fulfilment_status != "shipped" do
          OrderNotifier.deliver_shipping_notification(updated_order)
        end

        if attrs[:fulfilment_status] == "delivered" and order.fulfilment_status != "delivered" do
          Berrypod.Reviews.ReviewRequestWorker.enqueue(updated_order.id)
        end

        {:ok, updated_order}
      end
    end
  end

  @doc """
  Updates an order's fulfilment fields.
  """
  def update_fulfilment(%Order{} = order, attrs) do
    order
    |> Order.fulfilment_changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Lists orders that need fulfilment status polling (submitted or processing).
  """
  def list_submitted_orders do
    from(o in Order,
      where: o.fulfilment_status in ["submitted", "processing"],
      where: not is_nil(o.provider_order_id),
      order_by: [asc: o.submitted_at],
      preload: :items
    )
    |> Repo.all()
  end

  @doc """
  Lists paid orders for a given customer email, newest first, with items preloaded.

  Only returns paid orders — pending/failed orders aren't useful to show customers.
  """
  def list_orders_by_email(email) when is_binary(email) do
    normalised = String.downcase(String.trim(email))

    Order
    |> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
    |> where([o], o.payment_status == "paid")
    |> order_by([o], desc: o.inserted_at)
    |> preload(:items)
    |> Repo.all()
  end

  @doc """
  Gets an order by its order number.
  """
  def get_order_by_number(order_number) do
    Order
    |> where([o], o.order_number == ^order_number)
    |> preload(:items)
    |> Repo.one()
  end

  defp get_provider_connection_for_order(%Order{items: items}) do
    first_item = List.first(items)
    variants_map = Products.get_variants_with_products([first_item.variant_id])

    case Map.get(variants_map, first_item.variant_id) do
      nil ->
        {:error, :variant_not_found}

      variant ->
        case Products.get_provider_connection(variant.product.provider_connection_id) do
          nil -> {:error, :no_provider_connection}
          %{enabled: false} -> {:error, :provider_disabled}
          conn -> {:ok, conn}
        end
    end
  end

  defp enrich_items(items) do
    variant_ids = Enum.map(items, & &1.variant_id)
    variants_map = Products.get_variants_with_products(variant_ids)

    results =
      Enum.map(items, fn item ->
        case Map.get(variants_map, item.variant_id) do
          nil -> {:error, {:variant_not_found, item.variant_id, item.product_name}}
          variant -> {:ok, %{item: item, variant: variant}}
        end
      end)

    case Enum.find(results, &match?({:error, _}, &1)) do
      nil -> {:ok, Enum.map(results, fn {:ok, e} -> e end)}
      {:error, reason} -> {:error, reason}
    end
  end

  defp build_submission_data(order, enriched_items) do
    %{
      order_number: order.order_number,
      customer_email: order.customer_email,
      shipping_address: order.shipping_address,
      line_items:
        Enum.map(enriched_items, fn %{item: item, variant: variant} ->
          %{
            provider_product_id: variant.product.provider_product_id,
            provider_variant_id: variant.provider_variant_id,
            quantity: item.quantity
          }
        end)
    }
  end

  defp format_submission_error({:variant_not_found, _id, name}) do
    "Variant for '#{name}' no longer exists in the product catalog"
  end

  defp format_submission_error(:variant_not_found) do
    "Order variant no longer exists in the product catalog"
  end

  defp format_submission_error(:no_provider_connection) do
    "No fulfilment provider connected"
  end

  defp format_submission_error(:provider_disabled) do
    "Fulfilment provider is disabled"
  end

  defp format_submission_error(:no_api_key) do
    "Provider API key is missing"
  end

  defp format_submission_error(:no_shop_id) do
    "Provider shop ID is not configured"
  end

  defp format_submission_error({status, body}) when is_integer(status) do
    message = if is_map(body), do: body["message"] || body["error"], else: inspect(body)
    "Provider API error (#{status}): #{message}"
  end

  defp format_submission_error(reason) do
    "Submission failed: #{inspect(reason)}"
  end

  defp maybe_set_timestamp(attrs, order) do
    attrs
    |> maybe_set(:shipped_at, attrs[:fulfilment_status] == "shipped" and is_nil(order.shipped_at))
    |> maybe_set(
      :delivered_at,
      attrs[:fulfilment_status] == "delivered" and is_nil(order.delivered_at)
    )
  end

  defp maybe_set(attrs, key, true),
    do: Map.put(attrs, key, DateTime.utc_now() |> DateTime.truncate(:second))

  defp maybe_set(attrs, _key, false), do: attrs

  # =============================================================================
  # Abandoned cart
  # =============================================================================

  @doc """
  Creates an abandoned cart record.
  """
  def create_abandoned_cart(attrs) do
    %AbandonedCart{}
    |> AbandonedCart.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Gets an abandoned cart by ID.
  """
  def get_abandoned_cart(id) do
    Repo.get(AbandonedCart, id)
  end

  @doc """
  Gets an abandoned cart by Stripe session ID, or nil if not found.
  """
  def get_abandoned_cart_by_session(stripe_session_id) do
    Repo.get_by(AbandonedCart, stripe_session_id: stripe_session_id)
  end

  @doc """
  Stamps an abandoned cart as emailed.
  """
  def mark_abandoned_cart_emailed(cart) do
    cart
    |> AbandonedCart.changeset(%{emailed_at: DateTime.utc_now() |> DateTime.truncate(:second)})
    |> Repo.update()
  end

  @doc """
  Returns true if the given email has a paid order within the last `hours` hours.

  Used to skip recovery emails for customers who completed a purchase shortly
  after their session expired (e.g. started over in a new tab).
  """
  def has_recent_paid_order?(email, hours \\ 48) do
    normalised = String.downcase(String.trim(email))
    cutoff = DateTime.add(DateTime.utc_now(), -(hours * 3600), :second)

    Order
    |> where([o], fragment("lower(trim(?))", o.customer_email) == ^normalised)
    |> where([o], o.payment_status == "paid")
    |> where([o], o.inserted_at >= ^cutoff)
    |> Repo.exists?()
  end

  # =============================================================================
  # Email suppression
  # =============================================================================

  @doc """
  Checks whether an email address is suppressed from marketing emails.

  Returns `:ok` if the address is not suppressed, `:suppressed` if it is.
  Only applies to marketing emails (cart recovery). Transactional emails
  (order confirmation, shipping) are not affected.
  """
  def check_suppression(email) do
    normalised = String.downcase(String.trim(email))

    case Repo.get_by(EmailSuppression, email: normalised) do
      nil -> :ok
      _suppression -> :suppressed
    end
  end

  @doc """
  Adds an email address to the suppression list.

  Idempotent — inserting an already-suppressed address is a no-op.
  """
  def add_suppression(email, reason \\ "unsubscribed") do
    normalised = String.downcase(String.trim(email))

    %EmailSuppression{}
    |> EmailSuppression.changeset(%{email: normalised, reason: reason})
    |> Repo.insert(on_conflict: :nothing, conflict_target: :email)
  end
end
