defmodule SimpleshopTheme.Products do
  @moduledoc """
  The Products context.

  Manages products synced from POD providers, including provider connections,
  products, images, and variants.
  """

  import Ecto.Query
  alias SimpleshopTheme.Repo
  alias SimpleshopTheme.Products.{ProviderConnection, Product, ProductImage, ProductVariant}

  # =============================================================================
  # Provider Connections
  # =============================================================================

  @doc """
  Returns the list of provider connections.
  """
  def list_provider_connections do
    Repo.all(ProviderConnection)
  end

  @doc """
  Gets a single provider connection.
  """
  def get_provider_connection(id) do
    Repo.get(ProviderConnection, id)
  end

  @doc """
  Gets a single provider connection, raising if not found.
  """
  def get_provider_connection!(id) do
    Repo.get!(ProviderConnection, id)
  end

  @doc """
  Gets a provider connection by type.
  """
  def get_provider_connection_by_type(provider_type) do
    Repo.get_by(ProviderConnection, provider_type: provider_type)
  end

  @doc """
  Creates a provider connection.
  """
  def create_provider_connection(attrs \\ %{}) do
    %ProviderConnection{}
    |> ProviderConnection.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a provider connection.
  """
  def update_provider_connection(%ProviderConnection{} = conn, attrs) do
    conn
    |> ProviderConnection.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a provider connection.
  """
  def delete_provider_connection(%ProviderConnection{} = conn) do
    Repo.delete(conn)
  end

  @doc """
  Updates the sync status of a provider connection.
  """
  def update_sync_status(%ProviderConnection{} = conn, status, synced_at \\ nil) do
    attrs = %{sync_status: status}
    attrs = if synced_at, do: Map.put(attrs, :last_synced_at, synced_at), else: attrs

    conn
    |> ProviderConnection.sync_changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Resets any stale "syncing" status to "idle".

  Called on application startup to recover from interrupted syncs
  (e.g., node shutdown while sync was running).
  """
  def reset_stale_sync_status do
    from(c in ProviderConnection, where: c.sync_status == "syncing")
    |> Repo.update_all(set: [sync_status: "idle"])
  end

  @doc """
  Returns the count of products for a provider connection.
  """
  def count_products_for_connection(nil), do: 0

  def count_products_for_connection(connection_id) do
    from(p in Product, where: p.provider_connection_id == ^connection_id, select: count())
    |> Repo.one()
  end

  @doc """
  Enqueues a product sync job for the given provider connection.
  Returns `{:ok, job}` or `{:error, changeset}`.
  """
  def enqueue_sync(%ProviderConnection{} = conn) do
    SimpleshopTheme.Sync.ProductSyncWorker.enqueue(conn.id)
  end

  # =============================================================================
  # Storefront queries
  # =============================================================================

  # Listing pages only need images (price/stock are denormalized on product)
  @listing_preloads [images: :image]
  # Detail page also needs variants for the variant selector
  @detail_preloads [images: :image, variants: []]

  @doc """
  Gets a single visible, active product by slug with full preloads (for detail page).
  """
  def get_visible_product(slug) do
    Product
    |> where([p], p.slug == ^slug and p.visible == true and p.status == "active")
    |> preload(^@detail_preloads)
    |> Repo.one()
  end

  @doc """
  Lists visible, active products with listing preloads (no variants).

  ## Options

    * `:sort` - sort order: "price_asc", "price_desc", "newest", "name_asc", "name_desc"
    * `:category` - filter by category name
    * `:on_sale` - if true, only products on sale
    * `:in_stock` - if true, only products in stock
    * `:limit` - max number of results
    * `:exclude` - product ID to exclude

  """
  def list_visible_products(opts \\ []) do
    Product
    |> where([p], p.visible == true and p.status == "active")
    |> apply_visible_filters(opts)
    |> apply_sort(opts[:sort])
    |> maybe_limit(opts[:limit])
    |> maybe_exclude(opts[:exclude])
    |> preload(^@listing_preloads)
    |> Repo.all()
  end

  @doc """
  Lists distinct categories from visible, active products.
  Returns a list of %{name: "Category", slug: "category"}.
  """
  def list_categories do
    from(p in Product,
      where: p.visible == true and p.status == "active" and not is_nil(p.category),
      select: p.category,
      distinct: true,
      order_by: p.category
    )
    |> Repo.all()
    |> Enum.map(fn name -> %{name: name, slug: Slug.slugify(name)} end)
  end

  @doc """
  Recomputes denormalized fields from a product's variants.
  Called after variant sync to keep cached fields up to date.
  """
  def recompute_cached_fields(%Product{} = product) do
    variants = Repo.all(from v in ProductVariant, where: v.product_id == ^product.id)

    available = Enum.filter(variants, &(&1.is_enabled and &1.is_available))
    cheapest = Enum.min_by(available, & &1.price, fn -> nil end)

    attrs = %{
      cheapest_price: if(cheapest, do: cheapest.price, else: 0),
      compare_at_price: if(cheapest, do: cheapest.compare_at_price),
      in_stock: available != [],
      on_sale: Enum.any?(variants, &ProductVariant.on_sale?/1)
    }

    product
    |> Product.recompute_changeset(attrs)
    |> Repo.update()
  end

  defp apply_visible_filters(query, opts) do
    query
    |> maybe_filter_category(opts[:category])
    |> maybe_filter_on_sale(opts[:on_sale])
    |> maybe_filter_in_stock(opts[:in_stock])
  end

  defp maybe_filter_category(query, nil), do: query
  defp maybe_filter_category(query, name), do: where(query, [p], p.category == ^name)

  defp maybe_filter_on_sale(query, true), do: where(query, [p], p.on_sale == true)
  defp maybe_filter_on_sale(query, _), do: query

  defp maybe_filter_in_stock(query, true), do: where(query, [p], p.in_stock == true)
  defp maybe_filter_in_stock(query, _), do: query

  defp apply_sort(query, "price_asc"), do: order_by(query, [p], asc: p.cheapest_price)
  defp apply_sort(query, "price_desc"), do: order_by(query, [p], desc: p.cheapest_price)
  defp apply_sort(query, "newest"), do: order_by(query, [p], desc: p.inserted_at)
  defp apply_sort(query, "name_asc"), do: order_by(query, [p], asc: p.title)
  defp apply_sort(query, "name_desc"), do: order_by(query, [p], desc: p.title)
  defp apply_sort(query, _), do: order_by(query, [p], desc: p.inserted_at)

  defp maybe_limit(query, nil), do: query
  defp maybe_limit(query, n) when is_integer(n), do: limit(query, ^n)

  defp maybe_exclude(query, nil), do: query
  defp maybe_exclude(query, id), do: where(query, [p], p.id != ^id)

  # =============================================================================
  # Products
  # =============================================================================

  @doc """
  Returns the list of products.

  ## Options

    * `:visible` - filter by visibility (boolean)
    * `:status` - filter by status (string)
    * `:category` - filter by category (string)
    * `:provider_connection_id` - filter by provider connection
    * `:preload` - list of associations to preload

  """
  def list_products(opts \\ []) do
    Product
    |> apply_product_filters(opts)
    |> order_by([p], desc: p.inserted_at)
    |> maybe_preload(opts[:preload])
    |> Repo.all()
  end

  @doc """
  Gets a single product by ID.
  """
  def get_product(id, opts \\ []) do
    Product
    |> maybe_preload(opts[:preload])
    |> Repo.get(id)
  end

  @doc """
  Gets a single product by slug.
  """
  def get_product_by_slug(slug, opts \\ []) do
    Product
    |> maybe_preload(opts[:preload])
    |> Repo.get_by(slug: slug)
  end

  @doc """
  Gets a product by provider connection and provider product ID.
  """
  def get_product_by_provider(provider_connection_id, provider_product_id) do
    Repo.get_by(Product,
      provider_connection_id: provider_connection_id,
      provider_product_id: provider_product_id
    )
  end

  @doc """
  Creates a product.
  """
  def create_product(attrs \\ %{}) do
    %Product{}
    |> Product.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a product.
  """
  def update_product(%Product{} = product, attrs) do
    product
    |> Product.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes a product.
  """
  def delete_product(%Product{} = product) do
    Repo.delete(product)
  end

  @doc """
  Upserts a product from provider data.

  Creates a new product if one doesn't exist for the given provider connection
  and provider product ID. Updates the existing product if checksum differs.

  Returns `{:ok, product, :created | :updated | :unchanged}`.
  """
  def upsert_product(%ProviderConnection{id: conn_id}, attrs) do
    provider_product_id = attrs[:provider_product_id] || attrs["provider_product_id"]
    new_checksum = Product.compute_checksum(attrs[:provider_data] || attrs["provider_data"])
    title = attrs[:title] || attrs["title"]

    attrs =
      attrs
      |> Map.put(:checksum, new_checksum)
      |> Map.put(:provider_connection_id, conn_id)

    # First check by provider_product_id
    case get_product_by_provider(conn_id, provider_product_id) do
      nil ->
        # Not found by provider ID - check by slug (same title = same product)
        slug = Slug.slugify(title)
        find_by_slug_or_insert(conn_id, slug, attrs, new_checksum)

      %Product{checksum: ^new_checksum} = product ->
        {:ok, product, :unchanged}

      product ->
        case update_product(product, attrs) do
          {:ok, product} -> {:ok, product, :updated}
          error -> error
        end
    end
  end

  # If product exists with same slug, update it (including new provider_product_id)
  # Otherwise insert new product
  defp find_by_slug_or_insert(conn_id, slug, attrs, new_checksum) do
    case get_product_by_slug(slug) do
      %Product{provider_connection_id: ^conn_id, checksum: ^new_checksum} = product ->
        # Same product, same checksum - just update the provider_product_id if changed
        if product.provider_product_id != attrs[:provider_product_id] do
          case update_product(product, %{provider_product_id: attrs[:provider_product_id]}) do
            {:ok, product} -> {:ok, product, :updated}
            error -> error
          end
        else
          {:ok, product, :unchanged}
        end

      %Product{provider_connection_id: ^conn_id} = product ->
        # Same product, different checksum - full update including new provider_product_id
        case update_product(product, attrs) do
          {:ok, product} -> {:ok, product, :updated}
          error -> error
        end

      _ ->
        # Not found or belongs to different connection - insert new
        do_insert_product(attrs)
    end
  end

  # Insert with conflict handling for race conditions
  defp do_insert_product(attrs) do
    case create_product(attrs) do
      {:ok, product} ->
        {:ok, product, :created}

      {:error, %Ecto.Changeset{errors: errors} = changeset} ->
        # Check if it's a unique constraint violation (race condition)
        if has_unique_constraint_error?(errors) do
          handle_insert_conflict(attrs, changeset)
        else
          {:error, changeset}
        end
    end
  end

  defp handle_insert_conflict(attrs, changeset) do
    conn_id = attrs[:provider_connection_id]
    provider_product_id = attrs[:provider_product_id]
    new_checksum = attrs[:checksum]

    case get_product_by_provider(conn_id, provider_product_id) do
      nil ->
        {:error, changeset}

      %Product{checksum: ^new_checksum} = product ->
        {:ok, product, :unchanged}

      product ->
        case update_product(product, attrs) do
          {:ok, product} -> {:ok, product, :updated}
          error -> error
        end
    end
  end

  defp has_unique_constraint_error?(errors) do
    Enum.any?(errors, fn
      {_field, {_msg, [constraint: :unique, constraint_name: _]}} -> true
      _ -> false
    end)
  end

  # =============================================================================
  # Product Images
  # =============================================================================

  @doc """
  Creates a product image.
  """
  def create_product_image(attrs \\ %{}) do
    %ProductImage{}
    |> ProductImage.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Gets a single product image by ID.
  """
  def get_product_image(id) do
    Repo.get(ProductImage, id)
  end

  @doc """
  Links a product image to a Media.Image by setting its image_id.
  """
  def link_product_image(%ProductImage{} = product_image, image_id) do
    product_image
    |> ProductImage.changeset(%{image_id: image_id})
    |> Repo.update()
  end

  @doc """
  Lists product images that need downloading (have src but no image_id).

  ## Options

    * `:limit` - maximum number of images to return (default: 100)
  """
  def list_pending_downloads(opts \\ []) do
    limit = Keyword.get(opts, :limit, 100)

    from(i in ProductImage,
      where: not is_nil(i.src) and is_nil(i.image_id),
      order_by: [asc: i.inserted_at],
      limit: ^limit
    )
    |> Repo.all()
  end

  @doc """
  Deletes all images for a product.
  """
  def delete_product_images(%Product{id: product_id}) do
    from(i in ProductImage, where: i.product_id == ^product_id)
    |> Repo.delete_all()
  end

  @doc """
  Syncs product images from a list of image data.

  Preserves existing image_id references when the URL hasn't changed.
  Returns a list of {:ok, image} tuples for images that need downloading.
  """
  def sync_product_images(%Product{id: product_id}, images) when is_list(images) do
    # Build map of existing images by position
    existing_by_position =
      from(i in ProductImage, where: i.product_id == ^product_id)
      |> Repo.all()
      |> Map.new(&{&1.position, &1})

    incoming_positions =
      images
      |> Enum.with_index()
      |> Enum.map(fn {image_data, index} -> image_data[:position] || index end)
      |> MapSet.new()

    # Delete orphaned positions (images no longer in the list)
    orphaned_ids =
      existing_by_position
      |> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)
      |> Enum.map(fn {_position, img} -> img.id end)

    if orphaned_ids != [] do
      from(i in ProductImage, where: i.id in ^orphaned_ids) |> Repo.delete_all()
    end

    # Upsert incoming images
    images
    |> Enum.with_index()
    |> Enum.map(fn {image_data, index} ->
      position = image_data[:position] || index
      src = image_data[:src]
      existing = Map.get(existing_by_position, position)

      cond do
        # Same URL at position - keep existing (preserve image_id)
        existing && existing.src == src ->
          {:ok, existing}

        # Different URL at position - update src, clear image_id (triggers re-download)
        existing ->
          existing
          |> ProductImage.changeset(%{src: src, alt: image_data[:alt], image_id: nil})
          |> Repo.update()

        # New position - create new
        true ->
          attrs =
            image_data
            |> Map.put(:product_id, product_id)
            |> Map.put(:position, position)

          create_product_image(attrs)
      end
    end)
  end

  # =============================================================================
  # Product Variants
  # =============================================================================

  @doc """
  Gets multiple variants by their IDs with associated products and images.

  Returns a map of variant_id => variant struct for efficient lookup.
  Used by Cart.hydrate/1 to fetch variant data for display.
  """
  def get_variants_with_products(variant_ids) when is_list(variant_ids) do
    from(v in ProductVariant,
      where: v.id in ^variant_ids,
      preload: [product: [images: :image]]
    )
    |> Repo.all()
    |> Map.new(&{&1.id, &1})
  end

  @doc """
  Creates a product variant.
  """
  def create_product_variant(attrs \\ %{}) do
    %ProductVariant{}
    |> ProductVariant.changeset(attrs)
    |> Repo.insert()
  end

  @doc """
  Updates a product variant.
  """
  def update_product_variant(%ProductVariant{} = variant, attrs) do
    variant
    |> ProductVariant.changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Deletes all variants for a product.
  """
  def delete_product_variants(%Product{id: product_id}) do
    from(v in ProductVariant, where: v.product_id == ^product_id)
    |> Repo.delete_all()
  end

  @doc """
  Gets a variant by product and provider variant ID.
  """
  def get_variant_by_provider(product_id, provider_variant_id) do
    Repo.get_by(ProductVariant,
      product_id: product_id,
      provider_variant_id: provider_variant_id
    )
  end

  @doc """
  Syncs product variants from a list of variant data.

  Upserts variants based on provider_variant_id.
  """
  def sync_product_variants(%Product{id: product_id}, variants) when is_list(variants) do
    existing_ids =
      from(v in ProductVariant,
        where: v.product_id == ^product_id,
        select: v.provider_variant_id
      )
      |> Repo.all()
      |> MapSet.new()

    incoming_ids =
      variants
      |> Enum.map(&(&1[:provider_variant_id] || &1["provider_variant_id"]))
      |> MapSet.new()

    # Delete variants that are no longer in the incoming list
    removed_ids = MapSet.difference(existing_ids, incoming_ids)

    if MapSet.size(removed_ids) > 0 do
      from(v in ProductVariant,
        where:
          v.product_id == ^product_id and v.provider_variant_id in ^MapSet.to_list(removed_ids)
      )
      |> Repo.delete_all()
    end

    # Upsert incoming variants
    Enum.map(variants, fn variant_data ->
      provider_variant_id =
        variant_data[:provider_variant_id] || variant_data["provider_variant_id"]

      attrs = Map.put(variant_data, :product_id, product_id)

      case get_variant_by_provider(product_id, provider_variant_id) do
        nil ->
          create_product_variant(attrs)

        existing ->
          update_product_variant(existing, attrs)
      end
    end)
  end

  # =============================================================================
  # Private Helpers
  # =============================================================================

  defp apply_product_filters(query, opts) do
    query
    |> filter_by_visible(opts[:visible])
    |> filter_by_status(opts[:status])
    |> filter_by_category(opts[:category])
    |> filter_by_provider_connection(opts[:provider_connection_id])
  end

  defp filter_by_visible(query, nil), do: query
  defp filter_by_visible(query, visible), do: where(query, [p], p.visible == ^visible)

  defp filter_by_status(query, nil), do: query
  defp filter_by_status(query, status), do: where(query, [p], p.status == ^status)

  defp filter_by_category(query, nil), do: query
  defp filter_by_category(query, category), do: where(query, [p], p.category == ^category)

  defp filter_by_provider_connection(query, nil), do: query

  defp filter_by_provider_connection(query, conn_id),
    do: where(query, [p], p.provider_connection_id == ^conn_id)

  defp maybe_preload(query, nil), do: query
  defp maybe_preload(query, preloads), do: preload(query, ^preloads)
end
