defmodule TarakanWeb.RepositoryLive.Index do
  use TarakanWeb, :live_view

  alias Tarakan.Activity
  alias Tarakan.AnalyticsCache
  alias Tarakan.Community
  alias Tarakan.FindingMemory
  alias Tarakan.Fixes
  alias Tarakan.Infestations
  alias Tarakan.ModelAnalytics
  alias Tarakan.Repositories
  alias Tarakan.Scans
  alias Tarakan.SecurityPosture
  alias Tarakan.Work
  alias TarakanWeb.Presence

  @presence_topic "registry:observers"

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Repositories.subscribe()
      Activity.subscribe()
      Community.subscribe()
      Phoenix.PubSub.subscribe(Tarakan.PubSub, @presence_topic)

      {:ok, _ref} = Presence.track(self(), @presence_topic, socket.id, %{})
    end

    {:ok,
     socket
     |> assign(:page_title, "Public security record")
     |> assign(
       :meta_description,
       "Local agents. Public Reports. Pick up Jobs or publish findings at a pinned commit."
     )
     |> assign(:canonical_path, ~p"/")
     |> assign(:stats, Repositories.registry_stats())
     |> assign(:contributor_count, Scans.public_contributor_count())
     |> assign(:open_tasks, Work.list_open_public_tasks())
     |> assign(:scan_queue, scan_queue())
     |> assign(:infestations_window, 7)
     |> assign(:infestations, home_infestations(7))
     |> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))
     |> assign(:observer_count, observer_count())
     |> assign(:shouts, chronological_shouts(socket.assigns.current_scope))
     |> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
     |> assign(:shout_form_version, 0)
     |> assign(:can_moderate_shouts, Community.can_moderate?(socket.assigns.current_scope))
     |> assign(:search_query, "")
     |> assign(:search_results, [])
     |> assign_record_effects()}
  end

  # Three aggregates over the whole record, identical for every visitor, on the
  # busiest page there is. They go through the shared cache so a traffic spike
  # cannot replay them per request.
  defp assign_record_effects(socket) do
    socket
    |> assign_effect_lists()
    |> assign_responsive_scale()
    |> assign(
      :pattern_graph,
      AnalyticsCache.fetch({:home_pattern_graph, 12}, &encoded_pattern_graph/0,
        on_unavailable: nil
      )
    )
  end

  # The slowest ranked median, used to scale the responsiveness bars.
  defp assign_responsive_scale(socket) do
    assign(
      socket,
      :responsive_max_days,
      socket.assigns.responsive
      |> Enum.map(& &1.median_days_to_fix)
      |> Enum.reject(&is_nil/1)
      |> Enum.max(fn -> 0 end)
    )
  end

  # nil rather than an empty graph: a record with nothing entangled yet should
  # render no field at all, not an empty one.
  defp encoded_pattern_graph do
    graph = Infestations.pattern_graph(patterns: 12)

    if length(graph.nodes) >= 2, do: Jason.encode!(graph)
  end

  defp assign_effect_lists(socket) do
    socket
    |> assign(
      :carried_fixes,
      AnalyticsCache.fetch(
        {:home_carried_fixes, 4},
        fn ->
          Fixes.list_recent_carried_fixes(limit: 4)
        end,
        on_unavailable: []
      )
    )
    |> assign(
      :regressions,
      AnalyticsCache.fetch(
        {:home_regressions, 4},
        fn ->
          FindingMemory.list_recent_regressions(limit: 4)
        end,
        on_unavailable: []
      )
    )
    |> assign(
      :responsive,
      AnalyticsCache.fetch(
        {:home_responsive, 5},
        fn ->
          SecurityPosture.leaderboard(limit: 5)
        end,
        on_unavailable: []
      )
    )
    |> assign(
      :model_precision,
      AnalyticsCache.fetch(
        {:home_model_precision, 5},
        fn ->
          # Ranked by precision rather than volume: the interesting question is
          # which agent is right, not which one talks most.
          ModelAnalytics.cached_model_scoreboard(limit: 25)
          |> Enum.filter(& &1.precision)
          |> Enum.sort_by(& &1.precision, :desc)
          |> Enum.take(5)
        end,
        on_unavailable: []
      )
    )
  end

  # Bar width relative to the slowest ranked repository. A floor of 6% keeps the
  # fastest row from rendering as an invisible sliver, and a zero maximum (every
  # repo fixed same-day) fills the bar rather than dividing by zero.
  defp home_bar_pct(_value, max) when max in [nil, 0, 0.0], do: 100

  defp home_bar_pct(nil, _max), do: 0

  defp home_bar_pct(value, max) do
    value |> Kernel./(max) |> Kernel.*(100) |> Float.round(1) |> max(6.0) |> min(100.0)
  end

  @queue_limit 8

  # list_shouts/2 serves "the most recent N", newest first. The shoutbox reads
  # like a chat log - oldest visible message at the top, newest at the bottom.
  defp chronological_shouts(scope) do
    scope |> Community.list_shouts() |> Enum.reverse()
  end

  defp scan_queue do
    Repositories.list_reviewable_repositories(
      status: "unscanned",
      limit: @queue_limit,
      listing: :listed
    )
  end

  @infestation_windows [7, 30, 90]

  # Homepage floor is 3 repos: 2-repo matches are often unrelated findings that
  # share a generic normalized title, so they would just add noise at scale.
  # `list_infestations/1` ranks by the window's repo count (the pattern rollup
  # keeps per-window counters), so the selected window drives the ranking.
  @home_infestation_min_repos 3

  defp home_infestations(days) when days in @infestation_windows do
    Infestations.list_infestations(min_repos: @home_infestation_min_repos, days: days, limit: 12)
  end

  @infestations_debounce_ms 15_000

  defp refresh_collective(socket) do
    socket
    |> assign(:contributor_count, Scans.public_contributor_count())
    |> assign(:open_tasks, Work.list_open_public_tasks())
    |> assign(:scan_queue, scan_queue())
    |> maybe_refresh_infestations()
  end

  defp maybe_refresh_infestations(socket) do
    now = System.monotonic_time(:millisecond)
    last = Map.get(socket.assigns, :infestations_refreshed_at, 0)

    if now - last >= @infestations_debounce_ms do
      socket
      |> assign(:infestations, home_infestations(socket.assigns.infestations_window))
      |> assign(:infestations_refreshed_at, now)
    else
      socket
    end
  end

  @impl true
  def handle_info({:activity, entry}, socket) do
    _entry = entry
    {:noreply, refresh_collective(socket)}
  end

  def handle_info({:repository_registered, _repository}, socket) do
    {:noreply,
     socket
     |> assign(:stats, Repositories.registry_stats())
     |> assign(:scan_queue, scan_queue())}
  end

  def handle_info({:repository_record_updated, repository}, socket) do
    _repository = repository

    {:noreply,
     socket
     |> assign(:stats, Repositories.registry_stats())
     |> refresh_collective()}
  end

  def handle_info(%Phoenix.Socket.Broadcast{event: "presence_diff"}, socket) do
    {:noreply, assign(socket, :observer_count, observer_count())}
  end

  def handle_info({event, _shout}, socket) when event in [:shout_posted, :shout_removed] do
    {:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
  end

  @impl true
  def handle_event("infestations_window", %{"days" => days}, socket)
      when days in ["7", "30", "90"] do
    days = String.to_integer(days)

    {:noreply,
     socket
     |> assign(:infestations_window, days)
     |> assign(:infestations, home_infestations(days))
     |> assign(:infestations_refreshed_at, System.monotonic_time(:millisecond))}
  end

  def handle_event("search", params, socket) do
    query = params |> Map.get("q", "") |> String.trim()

    {:noreply,
     socket
     |> assign(:search_query, query)
     |> assign(:search_results, Repositories.search_repositories(query))}
  end

  def handle_event(
        "post_shout",
        %{"shout" => attrs},
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    case Community.create_shout(socket.assigns.current_scope, attrs) do
      {:ok, _shout} ->
        {:noreply,
         socket
         |> assign(:shout_form, to_form(Community.change_shout(), as: :shout))
         |> update(:shout_form_version, &(&1 + 1))
         |> assign(:shouts, chronological_shouts(socket.assigns.current_scope))}

      {:error, :rate_limited} ->
        {:noreply, put_flash(socket, :error, "Slow down - a few shouts per minute is plenty.")}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply,
         socket
         |> assign(:shout_form, to_form(changeset, as: :shout))
         |> put_flash(:error, "Write a message before sending.")}

      {:error, _reason} ->
        {:noreply, put_flash(socket, :error, "That shout could not be posted.")}
    end
  end

  def handle_event("post_shout", _params, socket) do
    {:noreply, redirect(socket, to: ~p"/accounts/log-in")}
  end

  def handle_event(
        "remove_shout",
        %{"id" => id},
        %{assigns: %{current_scope: %{account: account}}} = socket
      )
      when not is_nil(account) do
    with {:ok, shout} <- Community.get_shout(id),
         {:ok, _removed} <-
           Community.remove_shout(socket.assigns.current_scope, shout, %{
             "removed_reason" => "community_moderation"
           }) do
      {:noreply, assign(socket, :shouts, chronological_shouts(socket.assigns.current_scope))}
    else
      _error -> {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
    end
  end

  def handle_event("remove_shout", _params, socket) do
    {:noreply, put_flash(socket, :error, "That shout could not be removed.")}
  end

  defp observer_count do
    @presence_topic |> Presence.list() |> map_size()
  end

  defp shout_time(%DateTime{} = datetime), do: Calendar.strftime(datetime, "%b %-d · %H:%M")
end
