defmodule Pinchflat.YtDlp.Media do
  @moduledoc """
  Contains utilities for working with singular pieces of media
  """

  @enforce_keys [
    :media_id,
    :title,
    :description,
    :original_url,
    :livestream,
    :short_form_content,
    :uploaded_at,
    :duration_seconds,
    :predicted_media_filepath
  ]

  defstruct [
    :media_id,
    :title,
    :description,
    :original_url,
    :livestream,
    :short_form_content,
    :uploaded_at,
    :duration_seconds,
    :playlist_index,
    :predicted_media_filepath
  ]

  alias __MODULE__
  alias Pinchflat.Utils.FunctionUtils
  alias Pinchflat.Metadata.MetadataFileHelpers

  @doc """
  Downloads a single piece of media (and possibly its metadata) directly to its
  final destination. Returns the parsed JSON output from yt-dlp.

  Returns {:ok, map()} | {:error, any, ...}.
  """
  def download(url, command_opts \\ [], addl_opts \\ []) do
    all_command_opts = [:no_simulate] ++ command_opts

    with {:ok, output} <- backend_runner().run(url, :download, all_command_opts, "after_move:%()j", addl_opts),
         {:ok, parsed_json} <- Phoenix.json_library().decode(output) do
      {:ok, parsed_json}
    else
      err -> err
    end
  end

  @doc """
  Determines if the media at the given URL is ready to be downloaded.
  Common examples of non-downloadable media are upcoming or in-progress live streams.

  Returns {:ok, :downloadable | :ignorable} | {:error, any}
  """
  def get_downloadable_status(url, addl_opts \\ []) do
    action = :get_downloadable_status
    command_opts = [:simulate, :skip_download]

    case backend_runner().run(url, action, command_opts, "%(.{live_status})j", addl_opts) do
      {:ok, output} ->
        output
        |> Phoenix.json_library().decode!()
        |> parse_downloadable_status()

      err ->
        err
    end
  end

  @doc """
  Downloads a thumbnail for a single piece of media. Usually used for
  downloading thumbnails for internal use

  Returns {:ok, ""} | {:error, any, ...}.
  """
  def download_thumbnail(url, command_opts \\ [], addl_opts \\ []) do
    all_command_opts = [:no_simulate, :skip_download, :write_thumbnail, convert_thumbnail: "jpg"] ++ command_opts

    # NOTE: it doesn't seem like this command actually returns anything in `after_move` since
    # we aren't downloading the main media file
    backend_runner().run(url, :download_thumbnail, all_command_opts, "after_move:%()j", addl_opts)
  end

  @doc """
  Returns a map representing the media at the given URL.
  Optionally takes a list of additional command options to pass to yt-dlp
  or configuration-related options to pass to the runner.

  Returns {:ok, %Media{}} | {:error, any, ...}.
  """
  def get_media_attributes(url, command_opts \\ [], addl_opts \\ []) do
    all_command_opts = [:simulate, :skip_download] ++ command_opts
    output_template = indexing_output_template()

    case backend_runner().run(url, :get_media_attributes, all_command_opts, output_template, addl_opts) do
      {:ok, output} ->
        output
        |> Phoenix.json_library().decode!()
        |> response_to_struct()
        |> FunctionUtils.wrap_ok()

      err ->
        err
    end
  end

  @doc """
  Returns the output template for yt-dlp's indexing command.

  NOTE: playlist_index is really only useful for playlists that will never change their order.
  NOTE: I've switched back to `original_url` (from `webpage_url`) since it's started indicating
        if something is a short via the URL again
  """
  def indexing_output_template do
    "%(.{id,title,live_status,original_url,description,aspect_ratio,duration,upload_date,timestamp,playlist_index,filename})j"
  end

  @doc """
  Transforms a response from yt-dlp into a struct. Interprets the response to
  determine if the media is short-form content.

  Returns %Media{}.
  """
  def response_to_struct(response) do
    %Media{
      media_id: response["id"],
      title: response["title"],
      description: response["description"],
      original_url: response["original_url"],
      livestream: !!response["live_status"] && response["live_status"] != "not_live",
      duration_seconds: response["duration"] && round(response["duration"]),
      short_form_content: response["original_url"] && short_form_content?(response),
      uploaded_at: response["upload_date"] && parse_uploaded_at(response),
      playlist_index: response["playlist_index"] || 0,
      predicted_media_filepath: response["filename"]
    }
  end

  defp short_form_content?(response) do
    if String.contains?(response["original_url"], "/shorts/") do
      true
    else
      # Sometimes shorts are returned without /shorts/ in the URL,
      # so we need to do our best to determine if it's a short. This
      # WILL returns false positives, but it's a best-effort approach
      # that should work for most cases. The aspect_ratio check is
      # based on a gut feeling and may need to be tweaked.
      #
      # These don't fail if duration or aspect_ratio are missing
      # due to Elixir's comparison semantics
      response["duration"] <= 180 && response["aspect_ratio"] <= 0.85
    end
  end

  defp parse_uploaded_at(%{"timestamp" => ts} = response) when is_number(ts) do
    case DateTime.from_unix(ts) do
      {:ok, datetime} -> datetime
      _ -> MetadataFileHelpers.parse_upload_date(response["upload_date"])
    end
  end

  # This field is needed before inserting into the database, but absence
  # of this field should fail at insert-time rather than here
  defp parse_uploaded_at(%{"upload_date" => nil}), do: nil
  defp parse_uploaded_at(response), do: MetadataFileHelpers.parse_upload_date(response["upload_date"])

  defp parse_downloadable_status(response) do
    case response["live_status"] do
      status when status in ["is_live", "is_upcoming", "post_live"] -> {:ok, :ignorable}
      status when status in ["was_live", "not_live"] -> {:ok, :downloadable}
      # This preserves my tenuous support for non-youtube sources.
      nil -> {:ok, :downloadable}
      _ -> {:error, "Unknown live status: #{response["live_status"]}"}
    end
  end

  defp backend_runner do
    # This approach lets us mock the command for testing
    Application.get_env(:pinchflat, :yt_dlp_runner)
  end
end
