lib/selecto/subfilter/parser.ex

defmodule Selecto.Subfilter.Parser do
  @moduledoc """
  Parse subfilter configurations into structured subfilter specs.

  This module handles parsing relationship paths like "film.rating" or "film.category.name"
  and filter specifications like "R", ["R", "PG-13"], or {:count, ">", 5} into structured
  data that can be used by the SQL generation system.

  ## Examples

      iex> Selecto.Subfilter.Parser.parse("film.rating", "R")
      {:ok, %Selecto.Subfilter.Spec{...}}

      iex> Selecto.Subfilter.Parser.parse("film", {:count, ">", 5})
      {:ok, %Selecto.Subfilter.Spec{...}}

      iex> Selecto.Subfilter.Parser.parse("film.category.name", "Action")
      {:ok, %Selecto.Subfilter.Spec{...}}
  """

  alias Selecto.Subfilter
  alias Selecto.Subfilter.{Spec, RelationshipPath, FilterSpec}

  @doc """
  Parse subfilter into standardized configuration.

  ## Parameters

  - `relationship_path` - String path like "film.rating" or "film.category.name"
  - `filter_spec` - Filter specification (value, tuple, list, etc.)
  - `opts` - Options including :strategy, :negate, etc.

  ## Examples

      parse("film.rating", "R")
      #=> {:ok, %Spec{relationship_path: %RelationshipPath{...}, ...}}

      parse("film.rating", ["R", "PG-13"], strategy: :in)
      #=> {:ok, %Spec{strategy: :in, ...}}

      parse("film", {:count, ">", 5})
      #=> {:ok, %Spec{filter_spec: %FilterSpec{type: :aggregation, ...}}}
  """
  @spec parse(String.t(), any(), keyword()) :: {:ok, Spec.t()} | {:error, Subfilter.Error.t()}
  def parse(relationship_path, filter_spec, opts \\ []) do
    with {:ok, parsed_path} <- parse_relationship_path(relationship_path),
         :ok <- validate_relationship_path(parsed_path),
         {:ok, parsed_filter} <- parse_filter_specification(filter_spec),
         {:ok, validated_opts} <- validate_options(opts) do
      # Auto-detect strategy if not explicitly provided
      explicit_strategy = Keyword.get(validated_opts, :strategy)
      strategy = explicit_strategy || auto_detect_strategy(parsed_filter)
      negate = Keyword.get(validated_opts, :negate, false)
      id = generate_subfilter_id(relationship_path, filter_spec)

      spec = %Spec{
        id: id,
        relationship_path: parsed_path,
        filter_spec: parsed_filter,
        strategy: strategy,
        negate: negate,
        opts: validated_opts
      }

      {:ok, spec}
    else
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Parse compound subfilter operations (AND/OR).

  ## Examples

      parse_compound(:and, [
        {"film.rating", "R"},
        {"film.release_year", {">", 2000}}
      ])
  """
  @spec parse_compound(:and | :or, [{String.t(), any()}], keyword()) ::
          {:ok, Subfilter.CompoundSpec.t()} | {:error, Subfilter.Error.t()}
  def parse_compound(compound_type, subfilter_specs, opts \\ [])
      when compound_type in [:and, :or] and is_list(subfilter_specs) do
    case parse_all_subfilters(subfilter_specs, opts) do
      {:ok, parsed_subfilters} ->
        compound_spec = %Subfilter.CompoundSpec{
          type: compound_type,
          subfilters: parsed_subfilters
        }

        {:ok, compound_spec}

      {:error, reason} ->
        {:error, reason}
    end
  end

  # Private implementation functions

  defp generate_subfilter_id(relationship_path, filter_spec) do
    # Create a unique but readable ID for the subfilter
    path_part = String.replace(relationship_path, ".", "_")

    spec_part =
      case filter_spec do
        spec when is_binary(spec) ->
          String.slice(spec, 0, 10)

        spec when is_list(spec) ->
          "list_#{length(spec)}"

        {op, val} when is_atom(op) and not is_list(val) ->
          "#{op}_#{val}"

        {op, val} when is_atom(op) and is_list(val) ->
          "#{op}_#{Keyword.get(val, :years, Keyword.get(val, :days, Keyword.get(val, :hours, "opts")))}"

        _ ->
          "complex"
      end

    "#{path_part}_#{spec_part}_#{:erlang.unique_integer([:positive])}"
  end

  # Auto-detect the best strategy based on filter specification
  defp auto_detect_strategy(%{type: :in_list}) do
    :in
  end

  defp auto_detect_strategy(%{type: :aggregation}) do
    :aggregation
  end

  defp auto_detect_strategy(_filter_spec) do
    # Default strategy for equality, comparisons, etc.
    :exists
  end

  defp parse_relationship_path(path) when is_binary(path) do
    case String.split(path, ".") do
      [] ->
        {:error,
         %Subfilter.Error{
           type: :invalid_relationship_path,
           message: "Empty relationship path",
           details: %{path: path}
         }}

      [table] ->
        # Single table - aggregation subfilter
        {:ok,
         %RelationshipPath{
           path_segments: [table],
           target_table: table,
           target_field: nil,
           is_aggregation: true
         }}

      [table, field] ->
        # Single relationship - table.field
        {:ok,
         %RelationshipPath{
           path_segments: [table],
           target_table: table,
           target_field: field,
           is_aggregation: false
         }}

      segments when length(segments) > 2 ->
        # Multi-level relationship - film.category.name
        [field | reversed_tables] = Enum.reverse(segments)
        tables = Enum.reverse(reversed_tables)

        {:ok,
         %RelationshipPath{
           path_segments: tables,
           target_table: List.last(tables),
           target_field: field,
           is_aggregation: false
         }}
    end
  end

  defp parse_relationship_path(path) do
    {:error,
     %Subfilter.Error{
       type: :invalid_relationship_path,
       message: "Relationship path must be a string",
       details: %{path: path, type: inspect(path)}
     }}
  end

  defp parse_filter_specification(spec)
       when is_binary(spec) or is_number(spec) or is_atom(spec) do
    {:ok,
     %FilterSpec{
       type: :equality,
       operator: "=",
       value: spec
     }}
  end

  defp parse_filter_specification(specs) when is_list(specs) do
    {:ok,
     %FilterSpec{
       type: :in_list,
       operator: "IN",
       values: specs
     }}
  end

  defp parse_filter_specification({operator, value})
       when operator in [">", "<", ">=", "<=", "!=", "<>", "="] do
    {:ok,
     %FilterSpec{
       type: :comparison,
       operator: operator,
       value: value
     }}
  end

  defp parse_filter_specification({"between", min_val, max_val}) do
    {:ok,
     %FilterSpec{
       type: :range,
       operator: "BETWEEN",
       min_value: min_val,
       max_value: max_val
     }}
  end

  defp parse_filter_specification({:count, operator, value})
       when operator in [">", "<", ">=", "<=", "=", "!="] do
    {:ok,
     %FilterSpec{
       type: :aggregation,
       agg_function: :count,
       operator: operator,
       value: value
     }}
  end

  defp parse_filter_specification({agg_func, operator, value})
       when agg_func in [:sum, :avg, :min, :max] and operator in [">", "<", ">=", "<=", "=", "!="] do
    {:ok,
     %FilterSpec{
       type: :aggregation,
       agg_function: agg_func,
       operator: operator,
       value: value
     }}
  end

  defp parse_filter_specification({:recent, opts}) when is_list(opts) do
    years = Keyword.get(opts, :years, 1)

    {:ok,
     %FilterSpec{
       type: :temporal,
       temporal_type: :recent_years,
       value: years
     }}
  end

  defp parse_filter_specification({:within_days, days}) when is_integer(days) and days > 0 do
    {:ok,
     %FilterSpec{
       type: :temporal,
       temporal_type: :within_days,
       value: days
     }}
  end

  defp parse_filter_specification({:within_hours, hours}) when is_integer(hours) and hours > 0 do
    {:ok,
     %FilterSpec{
       type: :temporal,
       temporal_type: :within_hours,
       value: hours
     }}
  end

  defp parse_filter_specification({:since_date, date}) do
    {:ok,
     %FilterSpec{
       type: :temporal,
       temporal_type: :since_date,
       value: date
     }}
  end

  defp parse_filter_specification(spec) do
    {:error,
     %Subfilter.Error{
       type: :invalid_filter_spec,
       message: "Unsupported filter specification",
       details: %{spec: spec, type: inspect(spec)}
     }}
  end

  defp validate_relationship_path(%RelationshipPath{path_segments: segments})
       when is_list(segments) do
    if Enum.all?(segments, &(is_binary(&1) and String.trim(&1) != "")) do
      :ok
    else
      {:error,
       %Subfilter.Error{
         type: :invalid_relationship_path,
         message: "Relationship path contains empty or invalid segments",
         details: %{segments: segments}
       }}
    end
  end

  defp validate_options(opts) when is_list(opts) do
    case validate_strategy_option(opts) do
      {:ok, validated_opts} ->
        case validate_negate_option(validated_opts) do
          {:ok, final_opts} -> {:ok, final_opts}
          {:error, reason} -> {:error, reason}
        end

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp validate_options(opts) do
    {:error,
     %Subfilter.Error{
       type: :invalid_filter_spec,
       message: "Options must be a keyword list",
       details: %{opts: opts}
     }}
  end

  defp validate_strategy_option(opts) do
    case Keyword.get(opts, :strategy) do
      # Default strategy will be set later
      nil ->
        {:ok, opts}

      strategy when strategy in [:exists, :in, :any, :all] ->
        {:ok, opts}

      invalid_strategy ->
        {:error,
         %Subfilter.Error{
           type: :invalid_filter_spec,
           message: "Invalid strategy option",
           details: %{strategy: invalid_strategy, valid_strategies: [:exists, :in, :any, :all]}
         }}
    end
  end

  defp validate_negate_option(opts) do
    case Keyword.get(opts, :negate) do
      nil ->
        {:ok, opts}

      negate when is_boolean(negate) ->
        {:ok, opts}

      invalid_negate ->
        {:error,
         %Subfilter.Error{
           type: :invalid_filter_spec,
           message: "Invalid negate option - must be boolean",
           details: %{negate: invalid_negate}
         }}
    end
  end

  defp parse_all_subfilters(subfilter_specs, default_opts) do
    parse_all_subfilters(subfilter_specs, default_opts, [])
  end

  defp parse_all_subfilters([], _default_opts, acc) do
    {:ok, Enum.reverse(acc)}
  end

  defp parse_all_subfilters([{path, spec} | rest], default_opts, acc) do
    case parse(path, spec, default_opts) do
      {:ok, parsed_spec} ->
        parse_all_subfilters(rest, default_opts, [parsed_spec | acc])

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp parse_all_subfilters([{path, spec, opts} | rest], default_opts, acc) do
    merged_opts = Keyword.merge(default_opts, opts)

    case parse(path, spec, merged_opts) do
      {:ok, parsed_spec} ->
        parse_all_subfilters(rest, default_opts, [parsed_spec | acc])

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp parse_all_subfilters([invalid | _rest], _default_opts, _acc) do
    {:error,
     %Subfilter.Error{
       type: :invalid_filter_spec,
       message: "Invalid subfilter specification in list",
       details: %{spec: invalid}
     }}
  end
end