lib/selecto_components/views/graph/component.ex

defmodule SelectoComponents.Views.Graph.Component do
  @doc """
  Display results as interactive charts using Chart.js
  """
  use Phoenix.LiveComponent
  require Logger
  alias SelectoComponents.Env
  alias SelectoComponents.ErrorHandling.ErrorBuilder
  alias SelectoComponents.QueryResults
  alias SelectoComponents.Theme

  def update(assigns, socket) do
    # Force a complete re-assignment to ensure LiveView recognizes data changes
    socket =
      socket
      |> assign(assigns)
      |> assign(:theme, Map.get(assigns, :theme, Theme.default_theme(:light)))

    if Env.dev?() do
      IO.puts("[theme-debug][Graph.Component] update theme=#{socket.assigns.theme.id}")
    end

    # Add a timestamp to force re-rendering if data changed
    socket = assign(socket, :last_update, System.system_time(:microsecond))

    {:ok, socket}
  end

  def render(assigns) do
    ~H"""
    <div class="graph-component-wrapper">
      <%= cond do %>
        <% assigns[:execution_error] -> %>
          {render_error_state(assigns)}
        <% assigns[:executed] == false -> %>
          {render_loading_state(assigns)}
        <% assigns[:executed] && assigns.query_results == nil -> %>
          {render_no_results_state(assigns)}
        <% assigns[:executed] && match?({_results, _fields, _aliases}, assigns.query_results) -> %>
          <% {results, _fields, aliases} = assigns.query_results %>
          {render_chart(assigns, results, aliases)}
        <% true -> %>
          {render_unknown_state(assigns)}
      <% end %>
    </div>
    """
  end

  defp render_error_state(assigns) do
    assigns = assign(assigns, :error_info, ErrorBuilder.normalize(assigns[:execution_error]))

    ~H"""
    <div class="flex min-h-64 items-center justify-center rounded-lg border p-6" style="background: var(--sc-danger-soft); border-color: color-mix(in srgb, var(--sc-danger) 35%, var(--sc-surface-border)); color: var(--sc-danger);">
      <div class="text-center max-w-2xl">
        <div class="mb-3 text-4xl">⚠️</div>
        <div class="mb-2 text-lg font-semibold">{@error_info.summary}</div>
        <div class="mb-2">{@error_info.user_message}</div>
        <div :if={@error_info.detail} class="mb-2 text-sm">{@error_info.detail}</div>
        <div :if={@error_info.suggestion} class="mt-4 text-sm" style="color: var(--sc-text-secondary);">
          Next step: {@error_info.suggestion}
        </div>

        <details :if={Env.dev?() && is_map(@error_info.debug) && map_size(@error_info.debug) > 0} class="mt-3 text-left">
          <summary class="cursor-pointer text-sm">
            Debug Details
          </summary>
          <pre class="mt-2 overflow-x-auto rounded p-2 text-xs" style="background: var(--sc-surface-bg-alt); color: var(--sc-text-primary);"><%= inspect(@error_info.debug, pretty: true) %></pre>
        </details>
      </div>
    </div>
    """
  end

  defp render_loading_state(assigns) do
    ~H"""
    <div class="flex h-64 items-center justify-center rounded-lg border" style="background: var(--sc-surface-bg-alt); border-color: var(--sc-surface-border); color: var(--sc-accent);">
      <div class="text-center">
        <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
        <div class="italic">Loading chart...</div>
      </div>
    </div>
    """
  end

  defp render_no_results_state(assigns) do
    ~H"""
    <div class="flex h-64 items-center justify-center rounded-lg border" style="background: var(--sc-danger-soft); border-color: color-mix(in srgb, var(--sc-danger) 35%, var(--sc-surface-border)); color: var(--sc-danger);">
      <div class="text-center">
        <div class="text-4xl mb-2">📊</div>
        <div class="font-semibold">No Data Available</div>
        <div class="text-sm mt-1">Query executed but returned no results for the chart.</div>
      </div>
    </div>
    """
  end

  defp render_unknown_state(assigns) do
    ~H"""
    <div class="flex h-64 items-center justify-center rounded-lg border" style="background: color-mix(in srgb, var(--sc-accent-soft) 45%, var(--sc-surface-bg)); border-color: var(--sc-surface-border); color: var(--sc-text-secondary);">
      <div class="text-center">
        <div class="font-semibold">Unknown Chart State</div>
        <div class="text-sm mt-1">
          Executed: {inspect(assigns[:executed])}<br />
          Query Results: {inspect(assigns.query_results != nil)}
        </div>
      </div>
    </div>
    """
  end

  defp render_chart(assigns, results, aliases) do
    # Transform query results into chart data
    chart_data = prepare_chart_data(assigns, results, aliases)
    chart_options = prepare_chart_options(assigns)
    chart_type = get_chart_type(assigns)

    # Generate unique ID for this chart instance
    chart_id = "graph-#{assigns[:id] || :rand.uniform(10000)}"

    assigns =
      assign(assigns,
        chart_data: chart_data,
        chart_options: chart_options,
        chart_type: chart_type,
        chart_id: chart_id
      )

    ~H"""
    <div class="rounded-lg border p-6" style="background: var(--sc-surface-bg); border-color: var(--sc-surface-border); color: var(--sc-text-primary);">
      <!-- Chart Header with Title and Controls -->
      <div class="flex items-center justify-between mb-6">
        <div>
          <h3 :if={get_in(@chart_options, [:title])} class="text-lg font-semibold" style="color: var(--sc-text-primary);">
            {get_in(@chart_options, [:title])}
          </h3>
        </div>
        <div class="flex items-center gap-2">
          <button
            data-export
            class={Theme.slot(@theme, :button_secondary) <> " px-3 py-1 text-xs leading-4 shadow-sm"}
          >
            📥 Export
          </button>
        </div>
      </div>
      
    <!-- Chart Container -->
      <div
        id={@chart_id}
        phx-hook=".GraphComponent"
        phx-update="ignore"
        data-chart-type={@chart_type}
        data-chart-data={Jason.encode!(@chart_data)}
        data-chart-options={Jason.encode!(@chart_options)}
        data-x-axis={get_x_axis_field(@selecto.set[:x_axis_groups])}
        class="relative"
        style="height: 400px;"
      >
        <canvas id={"#{@chart_id}-canvas"}></canvas>
      </div>

      <script :type={Phoenix.LiveView.ColocatedHook} name=".GraphComponent">
        export default {
          chart: null,
          
          mounted() {
            this.initializeChart();
          },

          updated() {
            this.updateChart();
          },

          destroyed() {
            if (this.chart) {
              this.chart.destroy();
              this.chart = null;
            }
          },

          initializeChart() {
            const canvas = this.el.querySelector('canvas');
            if (!canvas) return;

            if (!window.Chart) {
              // Leave server-rendered content intact if Chart.js is unavailable.
              return;
            }

            const chartData = JSON.parse(this.el.dataset.chartData || '{}');
            const chartOptions = JSON.parse(this.el.dataset.chartOptions || '{}');
            const chartType = this.el.dataset.chartType || 'bar';

            const pushEvent = (event, payload) => {
              this.pushEvent(event, payload);
            };

            try {
              this.chart = new Chart(canvas, {
                type: chartType,
                data: chartData,
                options: {
                  ...chartOptions,
                  onClick: (event, elements) => {
                    if (elements.length > 0) {
                      const element = elements[0];
                      const datasetIndex = element.datasetIndex;
                      const index = element.index;
                      const dataset = chartData.datasets[datasetIndex];
                      const value = dataset.data[index];
                      const label = chartData.labels[index];

                      const xFieldName = this.el.dataset.xAxis;
                      const yFieldName = dataset.label;

                      pushEvent('chart_click', {
                        label: label,
                        value: value,
                        dataset_label: dataset.label,
                        x_field: xFieldName,
                        y_field: yFieldName
                      });
                    }
                  }
                }
              });
            } catch (error) {
              console.error('Error initializing chart:', error);
            }
          },

          updateChart() {
            if (this.chart) {
              const chartData = JSON.parse(this.el.dataset.chartData || '{}');
              const chartOptions = JSON.parse(this.el.dataset.chartOptions || '{}');

              this.chart.data = chartData;
              this.chart.options = chartOptions;
              this.chart.update();
            } else {
              this.initializeChart();
            }
          }
        }
      </script>
      
    <!-- Chart Legend/Summary -->
      <div class="mt-4 text-sm" style="color: var(--sc-text-secondary);">
        <div class="flex items-center justify-between">
          <span>
            {chart_summary(@chart_data, @chart_type)}
          </span>
          <span class="text-xs" style="color: var(--sc-text-muted);">
            Click data points to drill down
          </span>
        </div>
      </div>
    </div>
    """
  end

  @doc """
  Transform query results into Chart.js compatible data format
  """
  def prepare_chart_data(assigns, results, aliases) do
    chart_type = get_chart_type(assigns)

    # Get the selecto configuration to understand the data structure
    selecto_set = assigns.selecto.set
    x_axis_groups = selecto_set[:x_axis_groups] || []
    y_axis_aggregates = selecto_set[:aggregates] || []
    metric_defs = build_metric_defs(selecto_set[:graph_series_defs], y_axis_aggregates)
    series_groups = selecto_set[:series_groups] || []

    case chart_type do
      type when type in ["pie", "doughnut"] ->
        prepare_pie_data(results, aliases, x_axis_groups, metric_defs)

      type when type in ["line", "area"] ->
        prepare_line_data(results, aliases, x_axis_groups, metric_defs, series_groups, chart_type)

      "scatter" ->
        prepare_scatter_data(results, aliases, x_axis_groups, metric_defs, series_groups)

      # Default to bar chart
      _ ->
        prepare_bar_data(results, aliases, x_axis_groups, metric_defs, series_groups, chart_type)
    end
  end

  defp prepare_bar_data(results, _aliases, _x_axis_groups, metric_defs, series_groups, chart_type) do
    # Simplified for now
    num_x_fields = 1
    num_series_fields = Enum.count(series_groups)

    # Simple case: single X-axis, single or multiple Y-axis, no series
    if num_series_fields == 0 do
      labels = results |> Enum.map(fn row -> format_chart_label(Enum.at(row, 0)) end)

      datasets =
        metric_defs
        |> Enum.with_index()
        |> Enum.map(fn {metric_def, index} ->
          data =
            results
            |> Enum.map(fn row ->
              value = Enum.at(row, num_x_fields + index)
              format_numeric_value(value)
            end)

          series_type = dataset_type(metric_def, chart_type)

          dataset = %{
            label: metric_def.alias,
            data: data,
            yAxisID: axis_id(metric_def),
            borderColor: metric_color(metric_def, index, 1.0),
            borderWidth: 2
          }

          case series_type do
            "line" ->
              dataset
              |> Map.put(:type, "line")
              |> Map.put(:backgroundColor, metric_color(metric_def, index, 0.15))
              |> Map.put(:fill, false)
              |> Map.put(:tension, 0.35)

            _ ->
              dataset
              |> Map.put(:type, "bar")
              |> Map.put(:backgroundColor, metric_color(metric_def, index, 0.7))
          end
        end)

      %{labels: labels, datasets: datasets}
    else
      # More complex cases would be handled here
      %{labels: ["No Data"], datasets: [%{data: [0]}]}
    end
  end

  defp prepare_line_data(
         results,
         _aliases,
         x_axis_groups,
         metric_defs,
         series_groups,
         chart_type
       ) do
    num_x_fields = max(Enum.count(x_axis_groups), 1)
    num_series_fields = Enum.count(series_groups)

    filtered_results =
      results
      |> filter_rollup_rows(num_x_fields, num_series_fields)
      |> case do
        [] -> results
        rows -> rows
      end

    if num_series_fields == 0 do
      labels = Enum.map(filtered_results, &x_label(&1, num_x_fields))

      datasets =
        metric_defs
        |> Enum.with_index()
        |> Enum.map(fn {metric_def, index} ->
          data =
            Enum.map(filtered_results, fn row ->
              value = Enum.at(row, num_x_fields + index)
              format_numeric_value(value)
            end)

          series_type = dataset_type(metric_def, chart_type)

          dataset = %{
            label: metric_def.alias,
            data: data,
            yAxisID: axis_id(metric_def),
            borderColor: metric_color(metric_def, index, 1.0),
            borderWidth: 2
          }

          case series_type do
            "bar" ->
              dataset
              |> Map.put(:type, "bar")
              |> Map.put(:backgroundColor, metric_color(metric_def, index, 0.7))

            _ ->
              dataset
              |> Map.put(:type, "line")
              |> Map.put(:backgroundColor, metric_color(metric_def, index, 0.1))
              |> Map.put(:fill, false)
              |> Map.put(:tension, 0.4)
          end
        end)

      %{labels: labels, datasets: datasets}
    else
      labels =
        filtered_results
        |> Enum.map(&x_label(&1, num_x_fields))
        |> Enum.uniq()

      series_keys =
        filtered_results
        |> Enum.map(&series_key(&1, num_x_fields, num_series_fields))
        |> Enum.uniq()

      datasets =
        metric_defs
        |> Enum.with_index()
        |> Enum.flat_map(fn {metric_def, metric_index} ->
          series_keys
          |> Enum.with_index()
          |> Enum.map(fn {series_key, series_index} ->
            rows_for_series =
              Enum.filter(filtered_results, fn row ->
                series_key(row, num_x_fields, num_series_fields) == series_key
              end)

            values_by_label =
              Map.new(rows_for_series, fn row ->
                value = Enum.at(row, num_x_fields + num_series_fields + metric_index)
                {x_label(row, num_x_fields), format_numeric_value(value)}
              end)

            data = Enum.map(labels, &Map.get(values_by_label, &1, nil))
            series_type = dataset_type(metric_def, chart_type)
            dataset_offset = metric_index * max(length(series_keys), 1) + series_index

            dataset = %{
              label: "#{metric_def.alias} - #{format_series_key(series_key)}",
              data: data,
              yAxisID: axis_id(metric_def),
              borderColor: metric_color(metric_def, dataset_offset, 1.0),
              borderWidth: 2
            }

            case series_type do
              "bar" ->
                dataset
                |> Map.put(:type, "bar")
                |> Map.put(:backgroundColor, metric_color(metric_def, dataset_offset, 0.7))

              _ ->
                dataset
                |> Map.put(:type, "line")
                |> Map.put(:backgroundColor, metric_color(metric_def, dataset_offset, 0.1))
                |> Map.put(:fill, false)
                |> Map.put(:tension, 0.4)
            end
          end)
        end)

      %{labels: labels, datasets: datasets}
    end
  end

  defp filter_rollup_rows(results, num_x_fields, num_series_fields) do
    Enum.reject(results, fn row ->
      x_values = Enum.take(row, num_x_fields)
      series_values = Enum.slice(row, num_x_fields, num_series_fields)

      Enum.any?(x_values, &is_nil/1) || Enum.any?(series_values, &is_nil/1)
    end)
  end

  defp x_label(row, num_x_fields) do
    row
    |> Enum.take(num_x_fields)
    |> Enum.map(&format_chart_label/1)
    |> Enum.join(" / ")
  end

  defp series_key(row, num_x_fields, num_series_fields) do
    Enum.slice(row, num_x_fields, num_series_fields)
  end

  defp format_series_key(series_key) when is_list(series_key) do
    series_key
    |> Enum.map(&format_chart_label/1)
    |> Enum.join(" / ")
  end

  defp prepare_pie_data(results, _aliases, _x_axis_groups, _metric_defs) do
    labels = results |> Enum.map(fn row -> format_chart_label(Enum.at(row, 0)) end)
    data = results |> Enum.map(fn row -> format_numeric_value(Enum.at(row, 1)) end)

    %{
      labels: labels,
      datasets: [
        %{
          data: data,
          backgroundColor:
            Enum.with_index(labels) |> Enum.map(fn {_, i} -> generate_color(i, 0.8) end),
          borderColor:
            Enum.with_index(labels) |> Enum.map(fn {_, i} -> generate_color(i, 1.0) end),
          borderWidth: 1
        }
      ]
    }
  end

  defp prepare_scatter_data(
         _results,
         _aliases,
         _x_axis_groups,
         _metric_defs,
         _series_groups
       ) do
    # Simplified scatter data
    %{
      datasets: [
        %{
          label: "Scatter Data",
          data: [%{x: 0, y: 0}],
          backgroundColor: generate_color(0, 0.7),
          borderColor: generate_color(0, 1.0)
        }
      ]
    }
  end

  @doc """
  Prepare Chart.js options from view configuration
  """
  def prepare_chart_options(assigns) do
    chart_type = get_chart_type(assigns)

    selecto_set =
      case assigns[:selecto] do
        %{set: set} when is_map(set) -> set
        _ -> %{}
      end

    metric_defs =
      build_metric_defs(selecto_set[:graph_series_defs], selecto_set[:aggregates] || [])

    graph_options = selecto_set[:graph_options] || %{}
    uses_right_axis? = Enum.any?(metric_defs, &(&1.axis == "right"))

    base_options = %{
      title: Map.get(graph_options, "title"),
      responsive: truthy?(Map.get(graph_options, "responsive"), true),
      maintainAspectRatio: false,
      plugins: %{
        legend: %{position: Map.get(graph_options, "legend_position", "bottom")},
        tooltip: %{mode: "index", intersect: false}
      }
    }

    if chart_type in ["pie", "doughnut"] do
      base_options
    else
      scales = %{
        x: %{
          title: %{display: true, text: Map.get(graph_options, "x_axis_label", "")},
          beginAtZero: false,
          grid: %{display: truthy?(Map.get(graph_options, "show_grid"), true)}
        },
        y: %{
          type: "linear",
          position: "left",
          title: %{display: true, text: Map.get(graph_options, "y_axis_label", "")},
          beginAtZero: true,
          grid: %{display: truthy?(Map.get(graph_options, "show_grid"), true)}
        }
      }

      scales =
        if uses_right_axis? do
          Map.put(scales, :y1, %{
            type: "linear",
            position: "right",
            title: %{
              display: true,
              text: Map.get(graph_options, "y2_axis_label", "Secondary Axis")
            },
            beginAtZero: true,
            grid: %{drawOnChartArea: false}
          })
        else
          scales
        end

      Map.put(base_options, :scales, scales)
    end
  end

  def get_chart_type(assigns) do
    selecto_set =
      case assigns[:selecto] do
        %{set: set} when is_map(set) -> set
        _ -> %{}
      end

    chart_type =
      assigns[:chart_type] ||
        Map.get(selecto_set, :chart_type) ||
        Map.get(selecto_set, "chart_type") ||
        "bar"

    if is_atom(chart_type), do: Atom.to_string(chart_type), else: chart_type
  end

  defp truthy?(nil, default), do: default
  defp truthy?(v, _default) when v in [true, "true", "on", 1], do: true
  defp truthy?(_, _default), do: false

  def format_aggregate_label(aggregate) do
    get_aggregate_label(aggregate)
  end

  def get_aggregate_label({:field, {_fn, _field_name}, display_name})
      when is_binary(display_name) and display_name != "",
      do: display_name

  def get_aggregate_label({:field, {fn_name, field_name}, _display_name})
      when is_atom(fn_name) and is_binary(field_name),
      do: "#{fn_name}(#{field_name})"

  def get_aggregate_label({:field, _field_spec, display_name})
      when is_binary(display_name) and display_name != "",
      do: display_name

  def get_aggregate_label({:count, field_name}) when is_binary(field_name),
    do: "count(#{field_name})"

  def get_aggregate_label({:sum, field_name}) when is_binary(field_name), do: "sum(#{field_name})"
  def get_aggregate_label({:avg, field_name}) when is_binary(field_name), do: "avg(#{field_name})"
  def get_aggregate_label({:min, field_name}) when is_binary(field_name), do: "min(#{field_name})"
  def get_aggregate_label({:max, field_name}) when is_binary(field_name), do: "max(#{field_name})"
  def get_aggregate_label(_), do: "Value"

  def format_chart_label(value) when is_nil(value), do: "N/A"

  def format_chart_label({value, _meta}) when is_binary(value) or is_number(value),
    do: normalize_chart_label(value)

  def format_chart_label(value) when is_tuple(value) do
    case value do
      {:field, {:count, field_name}, display_name}
      when is_binary(field_name) and is_binary(display_name) ->
        display_name

      {:field, {:count, field_name}, _} when is_binary(field_name) ->
        field_name

      {:field, _field_spec, field_name} when is_binary(field_name) ->
        field_name

      {:field, _field_spec} ->
        "Field"

      _ ->
        to_string(value)
    end
  end

  def format_chart_label(value), do: normalize_chart_label(value)

  defp normalize_chart_label(value) when is_binary(value), do: QueryResults.normalize_value(value)
  defp normalize_chart_label(value), do: to_string(value)

  def format_numeric_value(value) when is_number(value), do: value
  def format_numeric_value({value, _meta}), do: format_numeric_value(value)
  def format_numeric_value(%Decimal{} = value), do: Decimal.to_float(value)

  def format_numeric_value(value) when is_binary(value) do
    case Integer.parse(value) do
      {int_value, ""} ->
        int_value

      _ ->
        case Float.parse(value) do
          {float_value, ""} -> float_value
          _ -> 0
        end
    end
  end

  def format_numeric_value(_), do: 0

  def generate_color(index, alpha) do
    colors = [
      # blue
      "59, 130, 246",
      # green
      "16, 185, 129",
      # red
      "245, 101, 101",
      # yellow
      "251, 191, 36",
      # purple
      "139, 92, 246",
      # pink
      "236, 72, 153",
      # cyan
      "6, 182, 212",
      # orange
      "251, 146, 60",
      # lime
      "34, 197, 94",
      # violet
      "168, 85, 247"
    ]

    color = Enum.at(colors, rem(index, length(colors)))
    "rgba(#{color}, #{alpha})"
  end

  def chart_summary(chart_data, chart_type) do
    dataset_count = length(chart_data[:datasets] || [])
    label_count = length(chart_data[:labels] || [])

    case chart_type do
      type when type in ["pie", "doughnut"] -> "#{label_count} categories"
      "scatter" -> "#{label_count} data points"
      _ -> "#{label_count} categories, #{dataset_count} series"
    end
  end

  defp get_x_axis_field(x_axis_groups) when is_list(x_axis_groups) do
    case x_axis_groups do
      [{_column, {:field, field_name, _alias}} | _] when is_binary(field_name) ->
        field_name

      [{_column, {:field, field_name, _alias}} | _] when is_atom(field_name) ->
        Atom.to_string(field_name)

      [{_column, {:field, field_name}} | _] when is_binary(field_name) ->
        field_name

      [{_column, {:field, field_name}} | _] when is_atom(field_name) ->
        Atom.to_string(field_name)

      [{_id, field, _config} | _] ->
        to_string(field)

      _ ->
        ""
    end
  end

  defp get_x_axis_field(_), do: ""

  defp build_metric_defs(graph_series_defs, _y_axis_aggregates) when is_list(graph_series_defs) do
    graph_series_defs
    |> Enum.map(fn defn ->
      %{
        alias: Map.get(defn, :alias) || "Value",
        series_type: Map.get(defn, :series_type, "auto"),
        axis: Map.get(defn, :axis, "left"),
        color: Map.get(defn, :color)
      }
    end)
  end

  defp build_metric_defs(_, y_axis_aggregates) do
    Enum.map(y_axis_aggregates, fn agg ->
      %{
        alias: format_aggregate_label(agg),
        series_type: "auto",
        axis: "left",
        color: nil
      }
    end)
  end

  defp dataset_type(metric_def, chart_type) do
    case metric_def.series_type do
      "bar" -> "bar"
      "line" -> "line"
      _ -> if(chart_type in ["line", "area"], do: "line", else: "bar")
    end
  end

  defp axis_id(%{axis: "right"}), do: "y1"
  defp axis_id(_), do: "y"

  defp metric_color(%{color: <<?#, _::binary>> = hex}, _index, alpha), do: hex_to_rgba(hex, alpha)
  defp metric_color(_, index, alpha), do: generate_color(index, alpha)

  defp hex_to_rgba("#" <> hex, alpha) when byte_size(hex) == 6 do
    <<r::binary-size(2), g::binary-size(2), b::binary-size(2)>> = hex
    {ri, _} = Integer.parse(r, 16)
    {gi, _} = Integer.parse(g, 16)
    {bi, _} = Integer.parse(b, 16)
    "rgba(#{ri}, #{gi}, #{bi}, #{alpha})"
  end

  defp hex_to_rgba(_, alpha), do: generate_color(0, alpha)
end