defmodule Berrypod.Providers.Printful do
  @moduledoc """
  Printful provider implementation.

  Handles product sync, order submission, and shipping rate lookups for Printful.
  Uses v2 API endpoints where available, v1 for sync products.
  """

  @behaviour Berrypod.Providers.Provider

  alias Berrypod.Clients.Printful, as: Client
  alias Berrypod.Products.ProviderConnection

  require Logger

  @impl true
  def provider_type, do: "printful"

  # =============================================================================
  # Connection
  # =============================================================================

  @impl true
  def test_connection(%ProviderConnection{} = conn) do
    with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
         :ok <- set_credentials(api_key, nil),
         {:ok, stores} <- Client.get_stores() do
      store = List.first(stores)

      {:ok,
       %{
         store_id: store["id"],
         store_name: store["name"]
       }}
    else
      nil -> {:error, :no_api_key}
      {:error, _} = error -> error
    end
  end

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

  @impl true
  def fetch_products(%ProviderConnection{config: config} = conn) do
    store_id = config["store_id"]

    if is_nil(store_id) do
      {:error, :no_store_id}
    else
      with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
           :ok <- set_credentials(api_key, store_id),
           {:ok, products} <- fetch_all_sync_products() do
        {:ok, products}
      else
        nil -> {:error, :no_api_key}
        {:error, _} = error -> error
      end
    end
  end

  defp fetch_all_sync_products do
    fetch_sync_products_page(0, [])
  end

  defp fetch_sync_products_page(offset, acc) do
    case Client.list_sync_products(offset: offset) do
      {:ok, products} when is_list(products) ->
        # Fetch full details for each product (includes variants + files)
        detailed =
          products
          |> Enum.with_index()
          |> Enum.map(fn {product, index} ->
            if index > 0, do: Process.sleep(100)

            case Client.get_sync_product(product["id"]) do
              {:ok, detail} ->
                sync_variants = detail["sync_variants"] || []
                catalog_colors = fetch_catalog_colors(sync_variants)
                normalize_product(detail["sync_product"], sync_variants, catalog_colors)

              {:error, reason} ->
                Logger.warning(
                  "Failed to fetch Printful product #{product["id"]}: #{inspect(reason)}"
                )

                nil
            end
          end)
          |> Enum.reject(&is_nil/1)

        all_products = acc ++ detailed

        # Printful paginates at 20 per page by default
        if length(products) >= 20 do
          Process.sleep(100)
          fetch_sync_products_page(offset + 20, all_products)
        else
          {:ok, all_products}
        end

      {:ok, _} ->
        {:ok, acc}

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

  # Fetch catalog color hex codes for a product's catalog type.
  # Caches per catalog_product_id in the process dictionary to avoid duplicate calls.
  defp fetch_catalog_colors(sync_variants) do
    catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
    cache_key = {:catalog_colors, catalog_product_id}

    case Process.get(cache_key) do
      nil ->
        colors = do_fetch_catalog_colors(catalog_product_id)
        Process.put(cache_key, colors)
        colors

      cached ->
        cached
    end
  end

  defp do_fetch_catalog_colors(0), do: %{}

  defp do_fetch_catalog_colors(catalog_product_id) do
    Process.sleep(100)

    case Client.get_catalog_product(catalog_product_id) do
      {:ok, catalog} ->
        (catalog["colors"] || [])
        |> Map.new(fn c -> {c["name"], c["value"]} end)

      {:error, reason} ->
        Logger.warning(
          "Failed to fetch catalog colors for product #{catalog_product_id}: #{inspect(reason)}"
        )

        %{}
    end
  end

  # =============================================================================
  # Orders
  # =============================================================================

  @impl true
  def submit_order(%ProviderConnection{config: config} = conn, order) do
    store_id = config["store_id"]

    if is_nil(store_id) do
      {:error, :no_store_id}
    else
      with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
           :ok <- set_credentials(api_key, store_id),
           order_data <- build_order_payload(order),
           {:ok, response} <- Client.create_order(order_data),
           order_id <- response["id"],
           {:ok, _confirmed} <- Client.confirm_order(order_id) do
        {:ok, %{provider_order_id: to_string(order_id)}}
      else
        nil -> {:error, :no_api_key}
        {:error, _} = error -> error
      end
    end
  end

  @impl true
  def get_order_status(%ProviderConnection{config: config} = conn, provider_order_id) do
    store_id = config["store_id"]

    if is_nil(store_id) do
      {:error, :no_store_id}
    else
      with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
           :ok <- set_credentials(api_key, store_id),
           {:ok, response} <- Client.get_order(provider_order_id) do
        shipments = fetch_shipments(provider_order_id)
        {:ok, normalize_order_status(response, shipments)}
      else
        nil -> {:error, :no_api_key}
        {:error, _} = error -> error
      end
    end
  end

  defp fetch_shipments(order_id) do
    case Client.get_order_shipments(order_id) do
      {:ok, shipments} when is_list(shipments) -> shipments
      _ -> []
    end
  end

  # =============================================================================
  # Shipping Rates
  # =============================================================================

  @impl true
  def fetch_shipping_rates(%ProviderConnection{config: config} = conn, products)
      when is_list(products) do
    store_id = config["store_id"]

    with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
         :ok <- set_credentials(api_key, store_id) do
      # Build per-product items: one representative variant per product
      product_items = extract_per_product_items(products)

      Logger.info(
        "Fetching Printful shipping rates for #{length(product_items)} product(s), " <>
          "#{length(target_countries())} countries"
      )

      rates =
        for {catalog_product_id, variant_id} <- product_items,
            {country_code, _index} <- Enum.with_index(target_countries()),
            reduce: [] do
          acc ->
            if acc != [], do: Process.sleep(100)
            acc ++ fetch_rate_for_product(catalog_product_id, variant_id, country_code)
        end

      {:ok, rates}
    else
      nil -> {:error, :no_api_key}
    end
  end

  # Countries to pre-cache rates for
  defp target_countries do
    ["GB", "US", "DE", "FR", "CA", "AU", "IE", "NL", "AT", "BE"]
  end

  defp fetch_rate_for_product(catalog_product_id, variant_id, country_code) do
    items = [%{source: "catalog", catalog_variant_id: variant_id, quantity: 1}]
    recipient = build_recipient(country_code)

    case Client.calculate_shipping(recipient, items) do
      {:ok, rates} when is_list(rates) ->
        standard = Enum.find(rates, &(&1["shipping"] == "STANDARD")) || List.first(rates)

        if standard do
          [
            %{
              blueprint_id: catalog_product_id,
              print_provider_id: 0,
              country_code: country_code,
              first_item_cost: parse_price(standard["rate"]),
              additional_item_cost: 0,
              currency: String.upcase(standard["currency"] || "USD"),
              handling_time_days: standard["max_delivery_days"]
            }
          ]
        else
          []
        end

      {:error, reason} ->
        Logger.warning(
          "Failed to fetch Printful shipping for #{country_code}: #{inspect(reason)}"
        )

        []
    end
  end

  # Printful requires state_code for US, CA, and AU
  @default_state_codes %{"US" => "NY", "CA" => "ON", "AU" => "NSW"}

  defp build_recipient(country_code) do
    base = %{country_code: country_code}

    case @default_state_codes[country_code] do
      nil -> base
      state -> Map.put(base, :state_code, state)
    end
  end

  # Returns {catalog_product_id, first_catalog_variant_id} per product
  defp extract_per_product_items(products) do
    products
    |> Enum.flat_map(fn product ->
      provider_data = product[:provider_data] || %{}

      catalog_product_id =
        provider_data[:catalog_product_id] || provider_data["catalog_product_id"]

      catalog_variant_ids =
        provider_data[:catalog_variant_ids] || provider_data["catalog_variant_ids"] || []

      case {catalog_product_id, catalog_variant_ids} do
        {nil, _} -> []
        {_, []} -> []
        {cpid, [vid | _]} -> [{cpid, vid}]
      end
    end)
  end

  # =============================================================================
  # Option Types (for frontend display)
  # =============================================================================

  @doc """
  Extracts option types from Printful provider_data for frontend display.

  Builds option types from the stored options list, which contains
  distinct colour and size values with optional hex codes.
  """
  def extract_option_types(%{"options" => options}) when is_list(options) do
    Enum.map(options, fn opt ->
      %{
        name: opt["name"],
        type: option_type_atom(opt["type"]),
        values:
          Enum.map(opt["values"] || [], fn val ->
            base = %{title: val["title"]}
            if val["hex"], do: Map.put(base, :hex, val["hex"]), else: base
          end)
      }
    end)
  end

  def extract_option_types(_), do: []

  defp option_type_atom("color"), do: :color
  defp option_type_atom("size"), do: :size
  defp option_type_atom(_), do: :other

  # =============================================================================
  # Data Normalization
  # =============================================================================

  defp normalize_product(sync_product, sync_variants, catalog_colors) do
    images = extract_preview_images(sync_variants)
    catalog_product_id = extract_catalog_product_id_from_variants(sync_variants)
    catalog_variant_ids = Enum.map(sync_variants, & &1["variant_id"]) |> Enum.reject(&is_nil/1)

    color_variant_map =
      sync_variants
      |> Enum.reject(fn sv -> is_nil(sv["color"]) end)
      |> Enum.uniq_by(fn sv -> sv["color"] end)
      |> Map.new(fn sv -> {normalize_text(sv["color"]), sv["variant_id"]} end)

    %{
      provider_product_id: to_string(sync_product["id"]),
      title: sync_product["name"],
      description: "",
      category: extract_category(sync_variants),
      images: images,
      variants: Enum.map(sync_variants, &normalize_variant/1),
      provider_data: %{
        catalog_product_id: catalog_product_id,
        catalog_variant_ids: catalog_variant_ids,
        color_variant_map: color_variant_map,
        # Shipping calc uses these generic keys (shared with Printify)
        blueprint_id: catalog_product_id,
        print_provider_id: 0,
        thumbnail_url: sync_product["thumbnail_url"],
        artwork_url: extract_artwork_url(sync_variants),
        options: build_option_types(sync_variants, catalog_colors),
        raw: %{sync_product: sync_product}
      }
    }
  end

  defp normalize_variant(sv) do
    %{
      provider_variant_id: to_string(sv["id"]),
      title: build_variant_title(sv),
      sku: sv["sku"],
      price: parse_price(sv["retail_price"]),
      cost: nil,
      options: build_variant_options(sv),
      is_enabled: sv["synced"] == true,
      is_available: sv["availability_status"] == "active"
    }
  end

  defp build_variant_title(sv) do
    parts = [sv["color"], sv["size"]] |> Enum.reject(&is_nil/1) |> Enum.map(&normalize_text/1)
    Enum.join(parts, " / ")
  end

  defp build_variant_options(sv) do
    opts = %{}
    opts = if sv["color"], do: Map.put(opts, "Color", normalize_text(sv["color"])), else: opts
    opts = if sv["size"], do: Map.put(opts, "Size", normalize_text(sv["size"])), else: opts
    opts
  end

  # Extract unique preview images from sync variants (one per unique image URL)
  defp extract_preview_images(sync_variants) do
    sync_variants
    |> Enum.flat_map(fn sv ->
      (sv["files"] || [])
      |> Enum.filter(&(&1["type"] == "preview"))
      |> Enum.map(fn file ->
        %{
          src: file["preview_url"] || file["thumbnail_url"],
          color: sv["color"],
          name: sv["name"]
        }
      end)
    end)
    |> Enum.uniq_by(& &1.src)
    |> Enum.with_index()
    |> Enum.map(fn {img, index} ->
      alt = if img.color not in [nil, ""], do: img.color, else: img.name
      color = if img.color not in [nil, ""], do: normalize_text(img.color), else: nil
      %{src: img.src, position: index, alt: alt, color: color}
    end)
  end

  # Find the artwork (design file) URL from the first variant's "default" file
  defp extract_artwork_url(sync_variants) do
    sync_variants
    |> Enum.find_value(fn sv ->
      (sv["files"] || [])
      |> Enum.find_value(fn
        %{"type" => "default", "url" => url} when is_binary(url) -> url
        _ -> nil
      end)
    end)
  end

  defp extract_catalog_product_id_from_variants(sync_variants) do
    sync_variants
    |> Enum.find_value(fn sv ->
      get_in(sv, ["product", "product_id"])
    end) || 0
  end

  # Build option types from variants for frontend display.
  # catalog_colors is a %{"Black" => "#0b0b0b", ...} map from the catalog API.
  defp build_option_types(sync_variants, catalog_colors) do
    colors =
      sync_variants
      |> Enum.reject(fn sv -> is_nil(sv["color"]) end)
      |> Enum.uniq_by(fn sv -> sv["color"] end)
      |> Enum.map(fn sv ->
        title = normalize_text(sv["color"])
        base = %{"title" => title}
        hex = Map.get(catalog_colors, sv["color"])
        if hex, do: Map.put(base, "colors", [hex]), else: base
      end)

    sizes =
      sync_variants
      |> Enum.map(& &1["size"])
      |> Enum.reject(&is_nil/1)
      |> Enum.map(&normalize_text/1)
      |> Enum.uniq()
      |> Enum.map(fn size -> %{"title" => size} end)

    opts = []

    opts =
      if colors != [],
        do: opts ++ [%{"name" => "Color", "type" => "color", "values" => colors}],
        else: opts

    opts =
      if sizes != [],
        do: opts ++ [%{"name" => "Size", "type" => "size", "values" => sizes}],
        else: opts

    opts
  end

  defp extract_category(sync_variants) do
    case sync_variants do
      [sv | _] ->
        product_name = get_in(sv, ["product", "name"]) || ""
        categorize_from_name(product_name)

      [] ->
        nil
    end
  end

  defp categorize_from_name(name) do
    name_lower = String.downcase(name)

    cond do
      has_keyword?(name_lower, ~w[t-shirt tshirt shirt hoodie sweatshirt jogger]) -> "Apparel"
      has_keyword?(name_lower, ~w[bag tote hat cap sleeve phone case]) -> "Accessories"
      has_keyword?(name_lower, ~w[mug cup blanket pillow cushion throw]) -> "Homewares"
      has_keyword?(name_lower, ~w[canvas poster print frame]) -> "Canvas Prints"
      has_keyword?(name_lower, ~w[notebook journal]) -> "Stationery"
      true -> "Apparel"
    end
  end

  defp has_keyword?(text, keywords) do
    Enum.any?(keywords, &String.contains?(text, &1))
  end

  # =============================================================================
  # Order Normalization
  # =============================================================================

  defp normalize_order_status(raw, shipments) do
    %{
      status: map_order_status(raw["status"]),
      provider_status: raw["status"],
      tracking_number: extract_tracking(shipments),
      tracking_url: extract_tracking_url(shipments),
      shipments: shipments
    }
  end

  defp map_order_status("draft"), do: "submitted"
  defp map_order_status("pending"), do: "submitted"
  defp map_order_status("inprocess"), do: "processing"
  defp map_order_status("fulfilled"), do: "shipped"
  defp map_order_status("shipped"), do: "shipped"
  defp map_order_status("delivered"), do: "delivered"
  defp map_order_status("canceled"), do: "cancelled"
  defp map_order_status("failed"), do: "submitted"
  defp map_order_status("onhold"), do: "submitted"
  defp map_order_status(_), do: "submitted"

  defp extract_tracking([shipment | _]) do
    shipment["tracking_number"] || get_in(shipment, ["tracking", "number"])
  end

  defp extract_tracking(_), do: nil

  defp extract_tracking_url([shipment | _]) do
    shipment["tracking_url"] || get_in(shipment, ["tracking", "url"])
  end

  defp extract_tracking_url(_), do: nil

  # =============================================================================
  # Order Building
  # =============================================================================

  defp build_order_payload(order_data) do
    %{
      external_id: order_data.order_number,
      shipping: "STANDARD",
      recipient: build_recipient(order_data.shipping_address, order_data.customer_email),
      items:
        Enum.map(order_data.line_items, fn item ->
          %{
            sync_variant_id: parse_int(item.provider_variant_id),
            quantity: item.quantity
          }
        end)
    }
  end

  defp build_recipient(address, email) when is_map(address) do
    %{
      name: address["name"] || "",
      address1: address["line1"] || address["address1"] || "",
      address2: address["line2"] || address["address2"] || "",
      city: address["city"] || "",
      country_code: address["country"] || "",
      state_code: address["state"] || address["region"] || "",
      zip: address["postal_code"] || address["zip"] || "",
      email: email
    }
  end

  defp build_recipient(_address, email) do
    %{
      name: "",
      address1: "",
      address2: "",
      city: "",
      country_code: "",
      state_code: "",
      zip: "",
      email: email
    }
  end

  # =============================================================================
  # Webhooks
  # =============================================================================

  @webhook_events [
    "package_shipped",
    "package_returned",
    "order_failed",
    "order_canceled",
    "product_synced",
    "product_updated",
    "product_deleted"
  ]

  @doc """
  Registers webhooks with Printful for this store.

  The webhook URL should include a token query param for verification,
  e.g. `https://example.com/webhooks/printful?token=SECRET`.
  """
  def register_webhooks(%ProviderConnection{config: config} = conn, webhook_url) do
    store_id = config["store_id"]
    secret = config["webhook_secret"]

    cond do
      is_nil(store_id) ->
        {:error, :no_store_id}

      is_nil(secret) or secret == "" ->
        {:error, :no_webhook_secret}

      true ->
        with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
             :ok <- set_credentials(api_key, store_id) do
          url_with_token = append_token(webhook_url, secret)
          Client.setup_webhooks(url_with_token, @webhook_events)
        else
          nil -> {:error, :no_api_key}
        end
    end
  end

  @doc """
  Lists currently registered webhooks for this store.
  """
  def list_webhooks(%ProviderConnection{config: config} = conn) do
    store_id = config["store_id"]

    with api_key when is_binary(api_key) <- ProviderConnection.get_api_key(conn),
         :ok <- set_credentials(api_key, store_id) do
      Client.get_webhooks()
    else
      nil -> {:error, :no_api_key}
    end
  end

  defp append_token(url, token) do
    uri = URI.parse(url)
    params = URI.decode_query(uri.query || "")
    query = URI.encode_query(Map.put(params, "token", token))
    URI.to_string(%{uri | query: query})
  end

  # =============================================================================
  # Helpers
  # =============================================================================

  # Parse a price string like "13.50" into integer pence (1350)
  defp parse_price(price) when is_binary(price) do
    case Float.parse(price) do
      {float, _} -> round(float * 100)
      :error -> 0
    end
  end

  defp parse_price(price) when is_number(price), do: round(price * 100)
  defp parse_price(_), do: 0

  defp parse_int(value) when is_integer(value), do: value
  defp parse_int(value) when is_binary(value), do: String.to_integer(value)

  # Printful uses Unicode double prime (″) and multiplication sign (×) in size
  # labels. These break LiveView's phx-value-* attribute serialization, so we
  # strip inch marks and normalise the multiplication sign.
  defp normalize_text(text) when is_binary(text) do
    text
    |> String.replace("″", "")
    |> String.replace("×", "x")
  end

  defp normalize_text(text), do: text

  defp set_credentials(api_key, store_id) do
    Process.put(:printful_api_key, api_key)
    if store_id, do: Process.put(:printful_store_id, store_id)
    :ok
  end
end
