defmodule MusicLibrary.Records.Similarity do
  @moduledoc """
  Functions for calculating and finding similar records based on embeddings.
  """

  import Ecto.Query
  import SqliteVec.Ecto.Query

  alias MusicLibrary.Artists
  alias MusicLibrary.Artists.ArtistInfo
  alias MusicLibrary.Notes
  alias MusicLibrary.Records
  alias MusicLibrary.Records.{Record, RecordEmbedding}
  alias MusicLibrary.Repo
  alias MusicLibrary.Worker.GenerateRecordEmbedding

  @max_distance Application.compile_env!(:music_library, :similarity)[:max_distance]

  @type find_opts :: [
          limit: pos_integer(),
          scope: :collection | :wishlist | nil,
          max_distance: float()
        ]

  @doc """
  Generates a text representation of a record for embedding generation.

  The representation includes:
  - Title
  - Artist names
  - Genres
  - Release year
  - Type (album, EP, etc.)
  - Per-artist blocks: name, country, disambiguation, Wikipedia summary (500 chars),
    Discogs profile excerpt (complementing Wikipedia when both available)
  - Last.fm community tags and similar artists (when stored)
  - User-written record notes (when present)
  """
  @spec text_representation(Record.t()) :: String.t()
  def text_representation(%Record{} = record) do
    artist_infos_map =
      record.artists
      |> Enum.map(& &1.musicbrainz_id)
      |> Artists.get_artist_infos()
      |> Map.new(&{&1.id, &1})

    artist_names = Record.artist_names(record)
    genres = Enum.join(record.genres, ", ")
    year = extract_year(record.release_date)
    type = humanize_type(record.type)
    note_text = record_note_text(record.musicbrainz_id)

    """
    Album: #{record.title}
    Artists: #{artist_names}
    Genres: #{genres}
    Released: #{year}
    Type: #{type}

    #{artist_blocks_summary(record.artists, artist_infos_map)}
    """
    |> String.trim()
    |> Kernel.<>(note_text)
  end

  defp artist_blocks_summary([], _artist_infos_map), do: ""

  defp artist_blocks_summary(artists, artist_infos_map) do
    artists
    |> Enum.map(fn artist ->
      artist_info = Map.get(artist_infos_map, artist.musicbrainz_id)
      artist_block(artist, artist_info)
    end)
    |> Enum.reject(&(&1 == ""))
    |> Enum.join("\n\n")
  end

  defp artist_block(_artist, nil), do: ""

  defp artist_block(artist, artist_info) do
    country = safe_artist_country(artist_info)
    disambiguation = non_empty_string(Map.get(artist, :disambiguation))

    header_extras = [country, disambiguation] |> Enum.reject(&is_nil/1)

    header =
      if header_extras == [] do
        artist.name
      else
        "#{artist.name} (#{Enum.join(header_extras, ", ")})"
      end

    content = artist_content(artist_info)

    if content == "" do
      ""
    else
      "#{header}:\n#{content}"
    end
  end

  defp artist_content(artist_info) do
    has_wikipedia = wikipedia_available?(artist_info)
    has_discogs = discogs_available?(artist_info)

    base_content =
      cond do
        has_wikipedia && has_discogs ->
          wikipedia = wikipedia_artist_summary(artist_info)
          discogs_excerpt = discogs_artist_excerpt(artist_info)
          [wikipedia, discogs_excerpt] |> Enum.reject(&(&1 == "")) |> Enum.join("\n")

        has_wikipedia ->
          wikipedia_artist_summary(artist_info)

        has_discogs ->
          discogs_artist_summary(artist_info)

        true ->
          ""
      end

    tags_line = lastfm_tags_line(artist_info)
    similar_line = lastfm_similar_line(artist_info)
    extras = [tags_line, similar_line] |> Enum.reject(&(&1 == ""))

    if extras == [] do
      base_content
    else
      [base_content | extras] |> Enum.reject(&(&1 == "")) |> Enum.join("\n")
    end
  end

  defp wikipedia_available?(artist_info) do
    ArtistInfo.wikipedia_description(artist_info) != nil ||
      ArtistInfo.wikipedia_summary(artist_info) != nil
  end

  defp discogs_available?(artist_info) do
    artist_info.discogs_data != nil &&
      (Map.get(artist_info.discogs_data, "profile_plaintext") != nil ||
         Map.get(artist_info.discogs_data, "profile") != nil)
  end

  defp wikipedia_artist_summary(artist_info) do
    description = ArtistInfo.wikipedia_description(artist_info) || ""
    summary = ArtistInfo.wikipedia_summary(artist_info) || ""
    truncated_summary = truncate_to_sentence(summary, 500)

    [description, truncated_summary]
    |> Enum.reject(&(&1 == ""))
    |> Enum.join(". ")
  end

  defp discogs_artist_summary(artist_info) do
    profile =
      Map.get(artist_info.discogs_data, "profile_plaintext") ||
        Map.get(artist_info.discogs_data, "profile") ||
        ""

    truncate_to_sentence(profile, 200)
  end

  defp discogs_artist_excerpt(artist_info) do
    profile =
      Map.get(artist_info.discogs_data, "profile_plaintext") ||
        Map.get(artist_info.discogs_data, "profile") ||
        ""

    truncate_to_sentence(profile, 150)
  end

  defp lastfm_tags_line(artist_info) do
    tags = ArtistInfo.lastfm_tags(artist_info)

    if tags == [] do
      ""
    else
      "Tags: #{tags |> Enum.take(10) |> Enum.join(", ")}"
    end
  end

  defp lastfm_similar_line(artist_info) do
    similar = ArtistInfo.lastfm_similar_artists(artist_info)

    if similar == [] do
      ""
    else
      "Similar artists: #{similar |> Enum.take(5) |> Enum.join(", ")}"
    end
  end

  defp safe_artist_country(artist_info) do
    case artist_info.musicbrainz_data do
      %{"area" => %{"name" => name}} when is_binary(name) -> name
      _ -> nil
    end
  end

  defp record_note_text(nil), do: ""

  defp record_note_text(musicbrainz_id) do
    case Notes.get_note(:record, musicbrainz_id) do
      %{content: content} when is_binary(content) and content != "" ->
        "\n\nNotes: #{content}"

      _ ->
        ""
    end
  end

  defp non_empty_string(nil), do: nil
  defp non_empty_string(""), do: nil
  defp non_empty_string(s), do: s

  @doc false
  def truncate_to_sentence(text, max_length) when byte_size(text) <= max_length, do: text

  def truncate_to_sentence(text, max_length) do
    truncated = String.slice(text, 0, max_length)

    case String.split(truncated, ~r/[.!?]\s/, include_captures: true) |> Enum.count() do
      count when count > 1 ->
        # Find the last sentence boundary within the limit
        truncated
        |> String.replace(~r/[^.!?]*$/, "")
        |> String.trim()
        |> case do
          "" -> String.trim(truncated)
          result -> result
        end

      _ ->
        String.trim(truncated)
    end
  end

  @doc """
  Finds similar records based on embedding similarity.

  ## Examples

      iex> find_similar("record-id-123", limit: 5)
      [%Record{}, ...]

      iex> find_similar("record-id-123", scope: :collection)
      [%Record{}, ...]
  """
  @spec find_similar(String.t(), find_opts()) :: [map()]
  def find_similar(record_id, opts \\ []) do
    limit = Keyword.get(opts, :limit, 10)
    scope = Keyword.get(opts, :scope)
    max_distance = Keyword.get(opts, :max_distance, @max_distance)

    record = Records.get_record!(record_id)
    record_musicbrainz_id = record.musicbrainz_id

    case get_embedding(record_id) do
      {:ok, source_embedding} ->
        query =
          from re in RecordEmbedding,
            where: re.record_id != ^record_id,
            join: r in Record,
            on: r.id == re.record_id and r.musicbrainz_id != ^record_musicbrainz_id,
            order_by: selected_as(:similarity),
            select: %{
              record: r,
              similarity:
                vec_distance_cosine(re.embedding, vec_f32(source_embedding))
                |> selected_as(:similarity)
            },
            group_by: r.musicbrainz_id,
            having: vec_distance_cosine(re.embedding, vec_f32(source_embedding)) <= ^max_distance,
            limit: ^limit

        query = apply_scope_filter(query, scope)

        query
        |> Repo.all()

      {:error, :not_found} ->
        []
    end
  end

  @spec get_embedding(String.t()) :: {:ok, binary()} | {:error, :not_found}
  def get_embedding(record_id) do
    case Repo.get_by(RecordEmbedding, record_id: record_id) do
      nil -> {:error, :not_found}
      embedding -> {:ok, embedding.embedding}
    end
  end

  @spec get_embedding_text(String.t()) :: {:ok, String.t()} | {:error, :not_found}
  def get_embedding_text(record_id) do
    case Repo.get_by(RecordEmbedding, record_id: record_id) do
      nil -> {:error, :not_found}
      embedding -> {:ok, embedding.text_representation}
    end
  end

  @spec store_embedding(String.t(), binary(), String.t()) ::
          {:ok, RecordEmbedding.t()} | {:error, Ecto.Changeset.t()}
  def store_embedding(record_id, embedding, text_representation) do
    attrs = %{
      record_id: record_id,
      embedding: embedding,
      text_representation: text_representation
    }

    %RecordEmbedding{}
    |> RecordEmbedding.changeset(attrs)
    |> Repo.insert(
      on_conflict: {:replace, [:embedding, :text_representation, :updated_at]},
      conflict_target: :record_id
    )
  end

  @spec generate_embedding_async(Record.t()) :: {:ok, Oban.Job.t()} | {:error, Ecto.Changeset.t()}
  def generate_embedding_async(record) do
    meta = %{title: record.title, artists: Enum.map(record.artists, & &1.name)}
    params = %{record_id: record.id}

    params
    |> GenerateRecordEmbedding.new(meta: meta)
    |> Oban.insert()
  end

  @spec generate_all_embeddings_async() :: non_neg_integer()
  def generate_all_embeddings_async do
    Record
    |> Repo.all()
    |> Enum.map(fn record ->
      meta = %{title: record.title, artists: Enum.map(record.artists, & &1.name)}
      params = %{record_id: record.id}

      params
      |> GenerateRecordEmbedding.new(meta: meta)
    end)
    |> Oban.insert_all()
    |> Enum.count()
  end

  # Private functions

  defp extract_year(nil), do: "Unknown"
  defp extract_year(""), do: "Unknown"

  defp extract_year(release_date) do
    case String.split(release_date, "-", parts: 2) do
      [year | _] -> year
      _ -> "Unknown"
    end
  end

  defp humanize_type(:album), do: "Album"
  defp humanize_type(:ep), do: "EP"
  defp humanize_type(:live), do: "Live"
  defp humanize_type(:compilation), do: "Compilation"
  defp humanize_type(:single), do: "Single"
  defp humanize_type(:other), do: "Other"
  defp humanize_type(_), do: "Unknown"

  defp apply_scope_filter(query, :collection) do
    from [re, r] in query, where: not is_nil(r.purchased_at)
  end

  defp apply_scope_filter(query, :wishlist) do
    from [re, r] in query, where: is_nil(r.purchased_at)
  end

  defp apply_scope_filter(query, _), do: query
end
