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

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

  import Ecto.Query
  alias Berrypod.Repo
  alias Berrypod.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 """
  Returns the first provider connection with an API key, or nil.

  Provider-agnostic — doesn't care which type. Ordered by creation date.
  """
  def get_first_provider_connection do
    ProviderConnection
    |> where([c], not is_nil(c.api_key_encrypted))
    |> order_by(:inserted_at)
    |> limit(1)
    |> Repo.one()
  end

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

  @doc """
  Tests an API key, creates the connection with config (shop_id etc),
  and enqueues a product sync. Used by both setup wizard and providers form.

  Returns `{:ok, connection}` or `{:error, reason}`.
  """
  def connect_provider(api_key, provider_type) do
    alias Berrypod.Providers

    encrypted =
      case Berrypod.Vault.encrypt(api_key) do
        {:ok, enc} -> enc
        _ -> nil
      end

    temp_conn = %ProviderConnection{
      provider_type: provider_type,
      api_key_encrypted: encrypted
    }

    with {:ok, test_result} <- Providers.test_connection(temp_conn) do
      name = provider_display_name(provider_type, test_result)
      config = provider_config(provider_type, test_result)

      params =
        %{"api_key" => api_key, "provider_type" => provider_type, "name" => name}
        |> then(fn p -> if config != %{}, do: Map.put(p, "config", config), else: p end)

      case create_provider_connection(params) do
        {:ok, connection} ->
          enqueue_sync(connection)
          {:ok, connection}

        {:error, _} = error ->
          error
      end
    end
  end

  defp provider_display_name("printify", %{shop_name: name}) when is_binary(name), do: name
  defp provider_display_name("printful", %{store_name: name}) when is_binary(name), do: name

  defp provider_display_name(type, _) do
    case Berrypod.Providers.Provider.get(type) do
      nil -> type
      info -> info.name
    end
  end

  defp provider_config("printify", %{shop_id: id}), do: %{"shop_id" => to_string(id)}
  defp provider_config("printful", %{store_id: id}), do: %{"store_id" => to_string(id)}
  defp provider_config(_, _), do: %{}

  @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 total count of all products.
  """
  def count_products do
    Repo.aggregate(Product, :count)
  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 """
  Returns active (non-discontinued) products for a provider connection.

  Used by product sync to detect products that are no longer returned by the provider.
  """
  def list_active_products_for_connection(connection_id) do
    from(p in Product,
      where: p.provider_connection_id == ^connection_id and p.status != "discontinued",
      select: [:id, :provider_product_id, :title, :status]
    )
    |> Repo.all()
  end

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

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

  # Image preload that skips the data blob — only loads :id and :source_width
  # from the images table. The data column holds the raw image binary (up to 5MB
  # per row) which is never used in rendering.
  defp image_preload_query do
    from(i in Berrypod.Media.Image, select: struct(i, [:id, :source_width]))
  end

  # Listing pages only need the first 2 images (primary + hover) and don't
  # need variants (price/stock are denormalized on the product row).
  defp listing_preloads do
    pi_query = from(pi in ProductImage, where: pi.position <= 1, order_by: pi.position)
    [images: {pi_query, image: image_preload_query()}]
  end

  # Skip provider_data (up to 72KB JSON) and description from listing queries —
  # neither is used on product cards. Same idea as image_preload_query().
  @listing_fields Product.__schema__(:fields) -- [:provider_data, :description]

  defp listing_select(query) do
    from(p in query, select: struct(p, ^@listing_fields))
  end

  @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")
    |> Repo.one()
    |> case do
      nil -> nil
      product -> Repo.preload(product, images: [image: image_preload_query()], variants: [])
    end
  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])
    |> listing_select()
    |> Repo.all()
    |> Repo.preload(listing_preloads())
  end

  @doc """
  Like `list_visible_products/1` but returns a `%Pagination{}` struct.

  Accepts the same filter/sort options plus `:page` and `:per_page`.
  """
  def list_visible_products_paginated(opts \\ []) do
    pagination =
      Product
      |> where([p], p.visible == true and p.status == "active")
      |> apply_visible_filters(opts)
      |> apply_sort(opts[:sort])
      |> maybe_exclude(opts[:exclude])
      |> listing_select()
      |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 24)

    %{pagination | items: Repo.preload(pagination.items, listing_preloads())}
  end

  @doc """
  Lists distinct categories from visible, active products.
  Returns a list of `%{name, slug, image_url}` where `image_url` is the
  first product image for a representative product in that category.

  Results are cached in ETS and invalidated when products change.
  """
  def list_categories do
    alias Berrypod.Settings.SettingsCache

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

      :miss ->
        categories = do_list_categories()
        SettingsCache.put_cached(:categories, categories)
        categories
    end
  end

  # Single query: categories with their first product image (by position).
  # Uses a correlated subquery to pick the lowest-position image per category,
  # replacing the previous N+1 pattern.
  defp do_list_categories do
    # Categories that have at least one product image
    with_images =
      from(p in Product,
        join: pi in ProductImage,
        on: pi.product_id == p.id,
        where: p.visible == true and p.status == "active" and not is_nil(p.category),
        where:
          pi.id ==
            fragment(
              """
              (SELECT pi2.id FROM product_images pi2
               JOIN products p2 ON pi2.product_id = p2.id
               WHERE p2.category = ? AND p2.visible = 1 AND p2.status = 'active'
               ORDER BY pi2.position ASC LIMIT 1)
              """,
              p.category
            ),
        group_by: p.category,
        order_by: p.category,
        select: {p.category, pi.image_id, pi.src}
      )
      |> Repo.all()

    categories_with_images = MapSet.new(with_images, &elem(&1, 0))

    # Categories without any images (still need to appear in the list)
    without_images =
      from(p in Product,
        where: p.visible == true and p.status == "active" and not is_nil(p.category),
        where: p.category not in ^MapSet.to_list(categories_with_images),
        select: p.category,
        distinct: true,
        order_by: p.category
      )
      |> Repo.all()
      |> Enum.map(&{&1, nil, nil})

    (with_images ++ without_images)
    |> Enum.sort_by(&elem(&1, 0))
    |> Enum.map(fn {name, image_id, src} ->
      image_url =
        cond do
          not is_nil(image_id) -> "/image_cache/#{image_id}-400.webp"
          is_binary(src) -> src
          true -> nil
        end

      %{name: name, slug: Slug.slugify(name), image_url: image_url}
    end)
  end

  @doc """
  Returns a list of all product slugs.

  Used for URL prefix redirects when admin changes the products prefix.
  """
  def list_product_slugs do
    from(p in Product,
      where: p.visible == true and p.status == "active",
      select: p.slug
    )
    |> Repo.all()
  end

  @doc """
  Returns a list of all category slugs.

  Used for URL prefix redirects when admin changes the collections prefix.
  """
  def list_category_slugs do
    list_categories()
    |> Enum.map(& &1.slug)
  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, false), do: where(query, [p], p.in_stock == false)
  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 """
  Returns products for the admin list page with sorting, stock filtering,
  and full preloads for display.

  ## Options

    * `:visible` - filter by visibility (boolean)
    * `:status` - filter by status (string)
    * `:category` - filter by category (string)
    * `:provider_connection_id` - filter by provider connection
    * `:in_stock` - filter by stock status (boolean)
    * `:sort` - sort order (string)

  """
  def list_products_admin(opts \\ []) do
    Product
    |> apply_product_filters(opts)
    |> maybe_filter_in_stock(opts[:in_stock])
    |> apply_sort(opts[:sort])
    |> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
    |> Repo.all()
  end

  @doc """
  Like `list_products_admin/1` but returns a `%Pagination{}` struct.

  Accepts the same filter options plus `:page` and `:per_page`.
  """
  def list_products_admin_paginated(opts \\ []) do
    Product
    |> apply_product_filters(opts)
    |> maybe_filter_in_stock(opts[:in_stock])
    |> apply_sort(opts[:sort])
    |> preload([:provider_connection, images: [image: ^image_preload_query()], variants: []])
    |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 25)
  end

  @doc """
  Returns distinct category names from all products (including hidden/draft).
  """
  def list_all_categories do
    from(p in Product,
      where: not is_nil(p.category),
      select: p.category,
      distinct: true,
      order_by: p.category
    )
    |> 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 product by ID with admin preloads (provider, images, variants).

  Excludes image blob data.
  """
  def get_product_with_preloads(id) do
    Product
    |> Repo.get(id)
    |> case do
      nil ->
        nil

      product ->
        Repo.preload(product, [
          :provider_connection,
          images: [image: image_preload_query()],
          variants: []
        ])
    end
  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 """
  Updates storefront-only fields (visibility and category).
  """
  def update_storefront(%Product{} = product, attrs) do
    result =
      product
      |> Product.storefront_changeset(attrs)
      |> Repo.update()

    case result do
      {:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
      _ -> :ok
    end

    result
  end

  @doc """
  Toggles a product's visibility.
  """
  def toggle_visibility(%Product{} = product) do
    update_storefront(product, %{visible: !product.visible})
  end

  @doc """
  Deletes a product.
  """
  def delete_product(%Product{} = product) do
    # Create a redirect before deletion so the old URL doesn't 404
    target =
      if product.category do
        "/collections/#{Slug.slugify(product.category)}"
      else
        "/"
      end

    Berrypod.Redirects.create_auto(%{
      from_path: "/products/#{product.slug}",
      to_path: target,
      source: "auto_product_deleted"
    })

    result = Repo.delete(product)

    case result do
      {:ok, _} -> Berrypod.Settings.SettingsCache.invalidate_cached(:categories)
      _ -> :ok
    end

    result
  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 ->
        old_slug = product.slug

        # Compute field-level diff before update
        changes = compute_product_diff(product, attrs)

        case update_product(product, attrs) do
          {:ok, updated} ->
            if old_slug != updated.slug do
              Berrypod.Redirects.create_auto(%{
                from_path: "/products/#{old_slug}",
                to_path: "/products/#{updated.slug}",
                source: "auto_slug_change"
              })
            end

            # Log field-level changes
            if changes != %{} do
              log_product_updated(updated, changes)
            end

            {:ok, updated, :updated}

          error ->
            error
        end
    end
  end

  # Compute diff between existing product and new attributes
  defp compute_product_diff(product, attrs) do
    fields = [:title, :description, :category]

    Enum.reduce(fields, %{}, fn field, acc ->
      old_val = Map.get(product, field)
      new_val = attrs[field] || attrs[to_string(field)]

      if old_val != new_val and not is_nil(new_val) do
        Map.put(acc, field, {old_val, new_val})
      else
        acc
      end
    end)
  end

  defp log_product_updated(product, changes) do
    changed_fields =
      changes
      |> Map.keys()
      |> Enum.map(&to_string/1)
      |> Enum.join(", ")

    Berrypod.ActivityLog.log_event(
      "sync.product_updated",
      "#{product.title}: #{changed_fields} changed",
      payload: %{product_id: product.id, changes: changes}
    )
  end

  defp log_product_created(product) do
    Berrypod.ActivityLog.log_event(
      "sync.product_added",
      "New product: #{product.title}",
      payload: %{product_id: product.id, title: product.title}
    )
  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

      nil ->
        # Not found at all - insert new
        do_insert_product(attrs)

      _different_connection ->
        # Slug taken by a different provider connection - make it unique
        unique_slug = make_unique_slug(slug)
        do_insert_product(Map.put(attrs, :slug, unique_slug))
    end
  end

  defp make_unique_slug(base_slug, suffix \\ 2) do
    candidate = "#{base_slug}-#{suffix}"

    case Repo.get_by(Product, slug: candidate) do
      nil -> candidate
      _ -> make_unique_slug(base_slug, suffix + 1)
    end
  end

  # Insert with conflict handling for race conditions
  defp do_insert_product(attrs) do
    case create_product(attrs) do
      {:ok, product} ->
        log_product_created(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 """
  Lists all images for a product, ordered by position.
  """
  def list_product_images(product_id) do
    from(i in ProductImage, where: i.product_id == ^product_id, order_by: i.position)
    |> Repo.all()
  end

  @doc """
  Updates a product image with the given attributes.
  """
  def update_product_image(%ProductImage{} = product_image, attrs) do
    product_image
    |> ProductImage.changeset(attrs)
    |> Repo.update()
  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, including their backing Media.Image records.
  """
  def delete_product_images(%Product{id: product_id}) do
    image_ids =
      from(pi in ProductImage,
        where: pi.product_id == ^product_id and not is_nil(pi.image_id),
        select: pi.image_id
      )
      |> Repo.all()

    result =
      from(pi in ProductImage, where: pi.product_id == ^product_id)
      |> Repo.delete_all()

    cleanup_orphaned_images(image_ids)

    result
  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 =
      existing_by_position
      |> Enum.reject(fn {position, _img} -> MapSet.member?(incoming_positions, position) end)

    orphaned_ids = Enum.map(orphaned, fn {_position, img} -> img.id end)

    orphaned_image_ids =
      orphaned
      |> Enum.map(fn {_position, img} -> img.image_id end)
      |> Enum.reject(&is_nil/1)

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

    # Upsert incoming images, collecting image_ids displaced by URL changes
    {results, replaced_image_ids} =
      images
      |> Enum.with_index()
      |> Enum.map_reduce([], fn {image_data, index}, acc ->
        position = image_data[:position] || index
        src = image_data[:src]
        existing = Map.get(existing_by_position, position)

        cond do
          # Same URL at position - update color if needed, preserve image_id
          existing && existing.src == src ->
            result =
              if existing.color != image_data[:color] do
                existing
                |> ProductImage.changeset(%{color: image_data[:color]})
                |> Repo.update()
              else
                {:ok, existing}
              end

            {result, acc}

          # Different URL at position - update src, clear image_id (triggers re-download)
          existing ->
            acc = if existing.image_id, do: [existing.image_id | acc], else: acc

            result =
              existing
              |> ProductImage.changeset(%{
                src: src,
                alt: image_data[:alt],
                color: image_data[:color],
                image_id: nil
              })
              |> Repo.update()

            {result, acc}

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

            {create_product_image(attrs), acc}
        end
      end)

    cleanup_orphaned_images(orphaned_image_ids ++ replaced_image_ids)

    results
  end

  # Deletes Media.Image records that are no longer referenced by any product_image.
  defp cleanup_orphaned_images([]), do: :ok

  defp cleanup_orphaned_images(image_ids) do
    alias Berrypod.Media.Image, as: ImageSchema

    from(i in ImageSchema, where: i.id in ^image_ids)
    |> Repo.delete_all()
  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: ^image_preload_query()]]]
    )
    |> Repo.all()
    |> Map.new(&{&1.id, &1})
  end

  @doc """
  Gets a variant by ID.
  """
  def get_variant(id) do
    Repo.get(ProductVariant, id)
  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, tracking availability changes
    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 ->
          # Check for availability changes before update
          old_availability = ProductVariant.availability(existing)

          new_availability =
            cond do
              attrs[:is_available] == true and attrs[:is_enabled] == true -> :available
              attrs[:is_available] == false and attrs[:is_enabled] == true -> :out_of_stock
              attrs[:is_enabled] == false -> :discontinued
              true -> :available
            end

          # Check for cost changes before update
          old_cost = existing.cost
          new_cost = attrs[:cost]

          result = update_product_variant(existing, attrs)

          # Log if availability changed to a worse state
          if old_availability == :available and new_availability != :available do
            log_variant_availability_change(existing, product_id, new_availability)
          end

          # Log significant cost changes (>5%)
          if old_cost && new_cost && old_cost > 0 do
            check_cost_change(existing, product_id, old_cost, new_cost)
          end

          result
      end
    end)
  end

  # Cost change threshold (5%)
  @cost_change_threshold 5

  defp check_cost_change(variant, product_id, old_cost, new_cost) do
    change_pct = abs((new_cost - old_cost) / old_cost * 100)

    if change_pct > @cost_change_threshold do
      # Load the product title for the log message
      product = Repo.get(Product, product_id)
      product_title = if product, do: product.title, else: "Unknown product"

      level = if change_pct > 20, do: "warning", else: "info"

      # Format costs as currency (they're stored in pence)
      old_formatted = format_cost(old_cost)
      new_formatted = format_cost(new_cost)

      Berrypod.ActivityLog.log_event(
        "sync.price_change",
        "#{product_title} (#{variant.title}): cost #{old_formatted} → #{new_formatted} (#{round(change_pct)}%)",
        level: level,
        payload: %{
          variant_id: variant.id,
          product_id: product_id,
          variant_title: variant.title,
          old_cost: old_cost,
          new_cost: new_cost,
          change_pct: Float.round(change_pct, 1)
        }
      )
    end
  end

  defp format_cost(cost_in_pence) do
    pounds = cost_in_pence / 100
    "£#{:erlang.float_to_binary(pounds, decimals: 2)}"
  end

  defp log_variant_availability_change(variant, product_id, new_availability) do
    # Load the product title for the log message
    product = Repo.get(Product, product_id)
    product_title = if product, do: product.title, else: "Unknown product"

    status_text = if new_availability == :out_of_stock, do: "out of stock", else: "discontinued"

    Berrypod.ActivityLog.log_event(
      "sync.variant_unavailable",
      "#{product_title} (#{variant.title}): now #{status_text}",
      level: "warning",
      payload: %{
        variant_id: variant.id,
        product_id: product_id,
        variant_title: variant.title,
        new_availability: new_availability
      }
    )
  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
