defmodule Berrypod.Media do
  @moduledoc """
  The Media context for managing images and file uploads.
  """

  import Ecto.Query, warn: false
  alias Berrypod.Repo
  alias Berrypod.Media.Image, as: ImageSchema
  alias Berrypod.Media.FaviconVariant
  alias Berrypod.Images.Optimizer
  alias Berrypod.Images.OptimizeWorker

  @doc """
  Uploads an image and stores it in the database.

  For non-SVG images:
  - Converts to lossless WebP for storage (26-41% smaller than PNG)
  - Extracts source dimensions for responsive variant generation
  - Enqueues background job to generate optimized variants (AVIF, WebP, JPEG at multiple sizes)

  ## Examples

      iex> upload_image(%{image_type: "logo", filename: "logo.png", ...})
      {:ok, %Image{}}

  """
  def upload_image(attrs) do
    attrs = prepare_image_attrs(attrs)

    case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
      {:ok, image} ->
        # Enqueue background job for non-SVG images
        unless image.is_svg do
          OptimizeWorker.enqueue(image.id)
        end

        invalidate_media_cache(image.image_type)
        {:ok, image}

      error ->
        error
    end
  end

  # Prepares image attributes, converting to lossless WebP and extracting dimensions
  defp prepare_image_attrs(%{data: data, content_type: content_type} = attrs)
       when is_binary(data) do
    if is_svg?(content_type, attrs[:filename]) do
      attrs
    else
      case Optimizer.to_optimized_webp(data) do
        {:ok, webp_data, width, height} ->
          attrs
          |> Map.put(:data, webp_data)
          |> Map.put(:content_type, "image/webp")
          |> Map.put(:file_size, byte_size(webp_data))
          |> Map.put(:source_width, width)
          |> Map.put(:source_height, height)
          |> Map.put(:variants_status, "pending")
          |> maybe_extract_dominant_colors(webp_data)

        {:error, _reason} ->
          # If conversion fails, store original image
          attrs
      end
    end
  end

  defp prepare_image_attrs(attrs), do: attrs

  # Extract dominant colors during upload for header images (used for contrast checking)
  defp maybe_extract_dominant_colors(%{image_type: "header"} = attrs, webp_data) do
    case Image.from_binary(webp_data) do
      {:ok, vips_image} ->
        case Image.dominant_color(vips_image, top_n: 5) do
          {:ok, colors} when is_list(colors) ->
            colors = if is_list(hd(colors)), do: colors, else: [colors]
            Map.put(attrs, :dominant_colors, Jason.encode!(colors))

          _ ->
            attrs
        end

      _ ->
        attrs
    end
  end

  defp maybe_extract_dominant_colors(attrs, _webp_data), do: attrs

  defp is_svg?(content_type, filename) do
    content_type == "image/svg+xml" or
      String.ends_with?(filename || "", ".svg")
  end

  @doc """
  Uploads an image from a LiveView upload entry.

  This handles consuming the upload and extracting metadata from the entry.

  ## Examples

      iex> upload_from_entry(socket, :logo_upload, fn path, entry -> ... end)
      {:ok, %Image{}}

  """
  def upload_from_entry(path, entry, image_type, extra_attrs \\ %{}) do
    file_binary = File.read!(path)

    base = %{
      image_type: image_type,
      filename: entry.client_name,
      content_type: entry.client_type,
      file_size: entry.client_size,
      data: file_binary
    }

    upload_image(Map.merge(base, extra_attrs))
  end

  @doc """
  Upload that stores raw image data without synchronous processing.

  Unlike `upload_from_entry/4`, this skips the WebP conversion step and
  defers all image processing to the background Oban worker. This is
  essential for multi-file uploads where synchronous processing would
  cause upload channel timeouts.

  The trade-off: images won't have `source_width`/`source_height` set
  until the background job runs, and won't display optimized variants
  immediately. Fine for review photos where we show a `live_img_preview`
  during upload and the final display can wait.
  """
  def upload_from_entry_async(path, entry, image_type) do
    file_binary = File.read!(path)

    attrs = %{
      image_type: image_type,
      filename: entry.client_name,
      content_type: entry.client_type,
      file_size: byte_size(file_binary),
      data: file_binary,
      variants_status: "pending"
    }

    case Repo.insert(ImageSchema.changeset(%ImageSchema{}, attrs)) do
      {:ok, image} ->
        # Enqueue background job for conversion and optimization
        OptimizeWorker.enqueue(image.id)
        invalidate_media_cache(image.image_type)
        {:ok, image}

      error ->
        error
    end
  end

  @doc """
  Gets a single image by ID.

  ## Examples

      iex> get_image(id)
      %Image{}

      iex> get_image("nonexistent")
      nil

  """
  def get_image(id) do
    Repo.get(ImageSchema, id)
  end

  @doc """
  Gets multiple images by their IDs.
  Returns images in the order they appear in the IDs list.
  """
  def get_images(ids) when is_list(ids) and ids != [] do
    images =
      Repo.all(
        from i in ImageSchema,
          where: i.id in ^ids
      )

    # Return in the order of the input IDs
    Map.new(images, &{&1.id, &1})
    |> then(fn map -> Enum.map(ids, &Map.get(map, &1)) end)
    |> Enum.reject(&is_nil/1)
  end

  def get_images(_), do: []

  @doc """
  Gets the current logo image.

  ## Examples

      iex> get_logo()
      %Image{}

  """
  def get_logo do
    alias Berrypod.Settings.SettingsCache

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

      :miss ->
        logo =
          Repo.one(
            from i in ImageSchema,
              where: i.image_type == "logo",
              order_by: [desc: i.inserted_at],
              limit: 1,
              select: struct(i, [:id, :image_type, :is_svg, :svg_content, :source_width])
          )

        SettingsCache.put_cached(:logo, logo)
        logo
    end
  end

  @doc """
  Gets the current header image.

  ## Examples

      iex> get_header()
      %Image{}

  """
  def get_header do
    alias Berrypod.Settings.SettingsCache

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

      :miss ->
        header =
          Repo.one(
            from i in ImageSchema,
              where: i.image_type == "header",
              order_by: [desc: i.inserted_at],
              limit: 1,
              select: struct(i, [:id, :image_type, :source_width, :dominant_colors])
          )

        SettingsCache.put_cached(:header, header)
        header
    end
  end

  @doc """
  Deletes an image.

  ## Examples

      iex> delete_image(image)
      {:ok, %Image{}}

  """
  def delete_image(%ImageSchema{} = image) do
    # Invalidate cache before delete to ensure stale data isn't served
    invalidate_media_cache(image.image_type)

    Repo.delete(image)
  end

  @doc """
  Lists all images of a specific type.

  ## Examples

      iex> list_images_by_type("logo")
      [%Image{}, ...]

  """
  def list_images_by_type(type) do
    Repo.all(from i in ImageSchema, where: i.image_type == ^type, order_by: [desc: i.inserted_at])
  end

  @doc """
  Gets the current icon image (used as favicon source when not using the logo).
  """
  def get_icon do
    Repo.one(
      from i in ImageSchema,
        where: i.image_type == "icon",
        order_by: [desc: i.inserted_at],
        limit: 1
    )
  end

  # ── Media library functions ──────────────────────────────────────

  @doc """
  Lists images with optional filters. Excludes BLOB data from the query
  for performance — returned images have `data: nil`.

  ## Options

    * `:type` — filter by image_type (e.g. "media", "product")
    * `:search` — search filename and alt text (case-insensitive)
    * `:tag` — filter by tag substring in comma-separated tags field

  """
  def list_images(opts \\ []) do
    query =
      from(i in ImageSchema,
        select: %{i | data: nil},
        order_by: [desc: i.inserted_at]
      )

    query
    |> filter_by_type(opts[:type])
    |> filter_by_search(opts[:search])
    |> filter_by_tag(opts[:tag])
    |> Repo.all()
  end

  @doc """
  Like `list_images/1` but returns a `%Pagination{}` struct.
  """
  def list_images_paginated(opts \\ []) do
    from(i in ImageSchema,
      select: %{i | data: nil},
      order_by: [desc: i.inserted_at]
    )
    |> filter_by_type(opts[:type])
    |> filter_by_search(opts[:search])
    |> filter_by_tag(opts[:tag])
    |> Berrypod.Pagination.paginate(page: opts[:page], per_page: opts[:per_page] || 36)
  end

  defp filter_by_type(query, nil), do: query
  defp filter_by_type(query, ""), do: query
  defp filter_by_type(query, type), do: where(query, [i], i.image_type == ^type)

  defp filter_by_search(query, nil), do: query
  defp filter_by_search(query, ""), do: query

  defp filter_by_search(query, term) do
    pattern = "%#{term}%"
    where(query, [i], like(i.filename, ^pattern) or like(i.alt, ^pattern))
  end

  defp filter_by_tag(query, nil), do: query
  defp filter_by_tag(query, ""), do: query

  defp filter_by_tag(query, tag) do
    pattern = "%#{tag}%"
    where(query, [i], like(i.tags, ^pattern))
  end

  @doc "Updates alt text, caption, and tags on an existing image."
  def update_image_metadata(%ImageSchema{} = image, attrs) do
    image
    |> ImageSchema.metadata_changeset(attrs)
    |> Repo.update()
  end

  @doc """
  Returns a list of places an image is referenced.

  Each usage is a map: `%{type: atom, label: String.t()}`.
  Scans product_images, theme settings, favicon variants, and page blocks.
  """
  def find_usages(image_id) when is_binary(image_id) do
    find_product_usages(image_id) ++
      find_theme_usages(image_id) ++
      find_favicon_usages(image_id) ++
      find_page_usages(image_id)
  end

  @doc """
  Returns a MapSet of all image IDs that are referenced somewhere.
  Used for orphan detection without per-image queries.
  """
  def used_image_ids do
    ids = MapSet.new()

    # Product images
    product_ids =
      from(pi in Berrypod.Products.ProductImage,
        where: not is_nil(pi.image_id),
        select: pi.image_id
      )
      |> Repo.all()

    ids = Enum.reduce(product_ids, ids, &MapSet.put(&2, &1))

    # Theme settings
    theme = Berrypod.Settings.get_theme_settings()

    ids =
      [:logo_image_id, :header_image_id, :icon_image_id]
      |> Enum.reduce(ids, fn field, acc ->
        case Map.get(theme, field) do
          nil -> acc
          id -> MapSet.put(acc, id)
        end
      end)

    # Favicon variants
    ids =
      case get_favicon_variants() do
        %{source_image_id: id} when not is_nil(id) -> MapSet.put(ids, id)
        _ -> ids
      end

    # Page block settings
    page_ids = scan_pages_for_image_ids()
    Enum.reduce(page_ids, ids, &MapSet.put(&2, &1))
  end

  @doc """
  Deletes an image and its disk variants.

  Returns `{:error, :in_use, usages}` if the image is still referenced.
  """
  def delete_with_cleanup(%ImageSchema{} = image) do
    usages = find_usages(image.id)

    if usages != [] do
      {:error, :in_use, usages}
    else
      cleanup_disk_variants(image.id)
      Repo.delete(image)
    end
  end

  # ── Usage scanning helpers ─────────────────────────────────────

  defp find_product_usages(image_id) do
    from(pi in Berrypod.Products.ProductImage,
      join: p in Berrypod.Products.Product,
      on: p.id == pi.product_id,
      where: pi.image_id == ^image_id,
      select: p.title
    )
    |> Repo.all()
    |> Enum.map(&%{type: :product, label: &1})
  end

  defp find_theme_usages(image_id) do
    theme = Berrypod.Settings.get_theme_settings()

    [
      {:logo_image_id, "Logo"},
      {:header_image_id, "Header background"},
      {:icon_image_id, "Site icon"}
    ]
    |> Enum.filter(fn {field, _} -> Map.get(theme, field) == image_id end)
    |> Enum.map(fn {_, label} -> %{type: :theme, label: label} end)
  end

  defp find_favicon_usages(image_id) do
    case get_favicon_variants() do
      %{source_image_id: ^image_id} -> [%{type: :favicon, label: "Favicon source"}]
      _ -> []
    end
  end

  defp find_page_usages(image_id) do
    Berrypod.Pages.list_all_pages()
    |> Enum.flat_map(fn page ->
      page.blocks
      |> Enum.filter(fn block ->
        settings = block["settings"] || %{}
        settings["image_id"] == image_id
      end)
      |> Enum.map(fn block ->
        %{type: :page, label: "#{page.title} — #{block["type"] || "block"}"}
      end)
    end)
  end

  defp scan_pages_for_image_ids do
    Berrypod.Pages.list_all_pages()
    |> Enum.flat_map(fn page ->
      Enum.flat_map(page.blocks, fn block ->
        case get_in(block, ["settings", "image_id"]) do
          nil -> []
          id -> [id]
        end
      end)
    end)
  end

  defp cleanup_disk_variants(image_id) do
    cache_dir = Optimizer.cache_dir()

    if File.exists?(cache_dir) do
      # Delete all files matching this image ID
      cache_dir
      |> File.ls!()
      |> Enum.filter(&String.starts_with?(&1, image_id))
      |> Enum.each(&File.rm(Path.join(cache_dir, &1)))
    end
  end

  # ── Favicon functions ──────────────────────────────────────────

  @doc """
  Gets the current favicon variants (single row).
  """
  def get_favicon_variants do
    Repo.one(
      from fv in FaviconVariant,
        order_by: [desc: fv.generated_at],
        limit: 1
    )
  end

  @doc """
  Stores favicon variants, replacing any existing set.
  """
  def store_favicon_variants(attrs) do
    Repo.delete_all(FaviconVariant)

    %FaviconVariant{}
    |> FaviconVariant.changeset(
      Map.put(attrs, :generated_at, DateTime.utc_now() |> DateTime.truncate(:second))
    )
    |> Repo.insert()
  end

  defp invalidate_media_cache("logo"),
    do: Berrypod.Settings.SettingsCache.invalidate_cached(:logo)

  defp invalidate_media_cache("header"),
    do: Berrypod.Settings.SettingsCache.invalidate_cached(:header)

  defp invalidate_media_cache(_), do: :ok
end
