defmodule Pinchflat.Media.MediaItem do
  @moduledoc """
  The MediaItem schema.
  """

  use Ecto.Schema
  use Pinchflat.Media.MediaQuery

  import Ecto.Changeset
  import Pinchflat.Utils.ChangesetUtils

  alias __MODULE__
  alias Pinchflat.Repo
  alias Pinchflat.Sources
  alias Pinchflat.Tasks.Task
  alias Pinchflat.Sources.Source
  alias Pinchflat.Metadata.MediaMetadata
  alias Pinchflat.Media.MediaItemsSearchIndex

  @allowed_fields [
    # these fields are only captured on index
    :playlist_index,
    # these fields are captured on indexing (and again on download)
    :title,
    :media_id,
    :description,
    :original_url,
    :livestream,
    :source_id,
    :short_form_content,
    :uploaded_at,
    :upload_date_index,
    :duration_seconds,
    :predicted_media_filepath,
    # these fields are captured only on download
    :media_downloaded_at,
    :media_filepath,
    :media_size_bytes,
    :subtitle_filepaths,
    :thumbnail_filepath,
    :metadata_filepath,
    :nfo_filepath,
    :last_error,
    # These are user or system controlled fields
    :prevent_download,
    :prevent_culling,
    :culled_at,
    :media_redownloaded_at
  ]
  # Pretty much all the fields captured at index are required.
  @required_fields ~w(
    uuid
    title
    original_url
    livestream
    media_id
    source_id
    uploaded_at
    short_form_content
  )a

  schema "media_items" do
    # This is _not_ used as the primary key or internally in the database
    # relations. This is only used to prevent an enumeration attack on the streaming
    # and RSS feed endpoints since those _must_ be public (ie: no basic auth)
    field :uuid, Ecto.UUID

    field :title, :string
    field :media_id, :string
    field :description, :string
    field :original_url, :string
    field :livestream, :boolean, default: false
    field :short_form_content, :boolean, default: false
    field :media_downloaded_at, :utc_datetime
    field :media_redownloaded_at, :utc_datetime
    field :uploaded_at, :utc_datetime
    field :upload_date_index, :integer, default: 0
    field :duration_seconds, :integer
    field :playlist_index, :integer, default: 0

    field :predicted_media_filepath, :string
    field :media_filepath, :string
    field :media_size_bytes, :integer
    field :thumbnail_filepath, :string
    field :metadata_filepath, :string
    field :nfo_filepath, :string
    # This is an array of [iso-2 language, filepath] pairs. Probably could
    # be an associated record, but I don't see the benefit right now.
    # Will very likely revisit because I can't leave well-enough alone.
    field :subtitle_filepaths, {:array, {:array, :string}}, default: []

    field :last_error, :string
    field :prevent_download, :boolean, default: false
    field :prevent_culling, :boolean, default: false
    field :culled_at, :utc_datetime

    field :matching_search_term, :string, virtual: true

    belongs_to :source, Source

    has_one :metadata, MediaMetadata, on_replace: :update
    has_one :media_items_search_index, MediaItemsSearchIndex, foreign_key: :id

    has_many :tasks, Task

    timestamps(type: :utc_datetime)
  end

  @doc false
  def changeset(media_item, attrs) do
    media_item
    |> cast(attrs, @allowed_fields)
    |> cast_assoc(:metadata, with: &MediaMetadata.changeset/2, required: false)
    |> dynamic_default(:uuid, fn _ -> Ecto.UUID.generate() end)
    |> update_upload_date_index()
    |> validate_required(@required_fields)
    # Validate that the title does NOT start with "youtube video #" since that indicates a restriction by YouTube.
    # See issue #549 for more information.
    |> validate_format(:title, ~r/^(?!youtube video #)/)
    |> unique_constraint([:media_id, :source_id])
  end

  @doc false
  def filepath_attributes do
    ~w(media_filepath thumbnail_filepath metadata_filepath subtitle_filepaths nfo_filepath)a
  end

  @doc false
  def filepath_attribute_defaults do
    filepath_attributes()
    |> Enum.map(fn
      :subtitle_filepaths -> {:subtitle_filepaths, []}
      field -> {field, nil}
    end)
    |> Enum.into(%{})
  end

  @doc false
  def json_exluded_fields do
    ~w(__meta__ __struct__ metadata tasks media_items_search_index)a
  end

  # Run it on new records no matter what. The method we delegate to
  # will handle the case where `uploaded_at` is `nil`
  defp update_upload_date_index(%{data: %{id: nil}} = changeset) do
    do_update_upload_date_index(changeset)
  end

  # For the update case, we only want to recalculate if the day itself has changed.
  # For instance, this is useful in the migration from `upload_date` to `uploaded_at`
  defp update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
    old_uploaded_at = changeset.data.uploaded_at
    new_uploaded_at = get_change(changeset, :uploaded_at)
    upload_dates_match = DateTime.to_date(old_uploaded_at) == DateTime.to_date(new_uploaded_at)

    if upload_dates_match do
      changeset
    else
      do_update_upload_date_index(changeset)
    end
  end

  # If the record is persisted and the `uploaded_at` field is not being changed,
  # we don't need to recalculate the index.
  defp update_upload_date_index(changeset), do: changeset

  defp do_update_upload_date_index(%{changes: changes} = changeset) when is_map_key(changes, :uploaded_at) do
    source_id = get_field(changeset, :source_id)
    source = Sources.get_source!(source_id)
    # Channels should count down from 99, playlists should count up from 0
    # This reflects the fact that channels prepend new videos to the top of the list
    # and playlists append new videos to the bottom of the list.
    default_index = if source.collection_type == :channel, do: 99, else: 0
    aggregator = if source.collection_type == :channel, do: :min, else: :max
    change_direction = if source.collection_type == :channel, do: -1, else: 1

    current_max =
      MediaQuery.new()
      |> where(^dynamic([mi], ^MediaQuery.upload_date_matches(changes.uploaded_at) and ^MediaQuery.for_source(source)))
      |> Repo.aggregate(aggregator, :upload_date_index)

    case current_max do
      nil -> put_change(changeset, :upload_date_index, default_index)
      max -> put_change(changeset, :upload_date_index, max + change_direction)
    end
  end

  defp do_update_upload_date_index(changeset), do: changeset

  defimpl Jason.Encoder, for: MediaItem do
    def encode(value, opts) do
      value
      |> Repo.preload(:source)
      |> Map.drop(MediaItem.json_exluded_fields())
      |> Jason.Encode.map(opts)
    end
  end
end
