Skip to main content

lib/rulestead_admin/live/flag_live/index.ex

# credo:disable-for-this-file
defmodule RulesteadAdmin.Live.FlagLive.Index do
  @moduledoc false

  use Phoenix.LiveView

  alias RulesteadAdmin.Components.{FlagComponents, OperatorComponents, Shell}
  alias RulesteadAdmin.Live.Session

  @default_limit 10
  @allowed_lifecycle ~w(active potentially_stale stale archived)
  @allowed_stale ~w(fresh potentially_stale stale)
  @allowed_readiness ~w(keep_active needs_review archive_candidate)
  @allowed_evidence_quality ~w(strong partial weak)
  @allowed_views ~w(all needs_review archive_candidates recently_stale archived custom)
  @inventory_views [
    {"all", "All flags",
     %{
       "readiness" => "",
       "stale" => "",
       "lifecycle" => "",
       "evidence_quality" => "",
       "include_archived" => "false"
     }},
    {"needs_review", "Review needed",
     %{
       "readiness" => "needs_review",
       "stale" => "",
       "lifecycle" => "",
       "evidence_quality" => "",
       "include_archived" => "false"
     }},
    {"archive_candidates", "Ready to archive",
     %{
       "readiness" => "archive_candidate",
       "stale" => "",
       "lifecycle" => "",
       "evidence_quality" => "",
       "include_archived" => "false"
     }},
    {"recently_stale", "Stale signal",
     %{
       "stale" => "stale",
       "readiness" => "",
       "lifecycle" => "",
       "evidence_quality" => "",
       "include_archived" => "false"
     }},
    {"archived", "Archived",
     %{
       "lifecycle" => "archived",
       "include_archived" => "true",
       "stale" => "",
       "readiness" => "",
       "evidence_quality" => ""
     }}
  ]

  @impl true
  def mount(_params, _session, socket) do
    socket =
      socket
      |> assign(:screen_action, :index)
      |> assign(:base_path, "#{socket.assigns.rulestead_admin_mount_path}/flags")
      |> assign(:filters, default_filters())
      |> assign(:page, empty_page())
      |> assign(:error_message, nil)
      |> assign(:outcome_notice, nil)
      |> assign(:outcome_audit_path, nil)
      |> assign(:highlighted_flag_key, nil)
      |> assign(:allowed_lifecycle, @allowed_lifecycle)
      |> assign(:allowed_stale, @allowed_stale)
      |> assign(:allowed_readiness, @allowed_readiness)
      |> assign(:allowed_evidence_quality, @allowed_evidence_quality)
      |> assign(:inventory_views, @inventory_views)
      |> assign(:omnisearch_suggestions, %{flags: [], owners: [], tags: []})
      |> assign(:omnisearch_input, "")
      |> stream_configure(:flags, dom_id: &"flag-#{&1.flag.key}")
      |> stream(:flags, [])

    {:ok, socket}
  end

  @impl true
  def handle_params(params, uri, socket) do
    merged_params = Map.merge(query_params(uri), stringify_keys(params))
    filters = normalize_filters(merged_params, socket.assigns.current_environment.key)
    outcome = normalize_outcome_params(merged_params)
    current_path = path_with_query(uri, socket.assigns.base_path)
    canonical_path = build_index_path(socket.assigns.base_path, filters, outcome)

    if canonical_path != current_path do
      {:noreply, push_patch(socket, to: canonical_path)}
    else
      socket =
        socket
        |> assign(:current_path, current_path)
        |> assign(:filters, filters)
        |> assign(:omnisearch_input, "")
        |> assign(
          :env_links,
          environment_links(
            socket.assigns.base_path,
            filters,
            socket.assigns.available_environments
          )
        )
        |> assign(
          :outcome_notice,
          outcome_notice(outcome, socket.assigns.current_environment.name)
        )
        |> assign(:outcome_audit_path, normalize_audit_path(socket, outcome["audit_path"]))
        |> assign(:highlighted_flag_key, outcome["highlight"])
        |> load_flags(filters)

      {:noreply, socket}
    end
  end

  @impl true
  def handle_event("omnisearch_changed", params, socket) do
    input = omnisearch_input_from_params(params)

    {:noreply, apply_transient_omnisearch(socket, input)}
  end

  @impl true
  def handle_event("filters_changed", %{"filters" => filters}, socket) do
    filters =
      case Map.get(filters, "query_text") do
        nil ->
          filters

        "" ->
          filters

        query_text ->
          Map.put(filters, "query", combined_query(socket.assigns.filters["query"], query_text))
      end

    merged_filters =
      socket.assigns.filters
      |> Map.merge(filters)
      |> Map.put("after", nil)
      |> Map.put("before", nil)

    {:noreply,
     push_patch(socket,
       to:
         build_index_path(
           socket.assigns.base_path,
           normalize_filters(merged_filters, socket.assigns.current_environment.key)
         )
     )}
  end

  @impl true
  def handle_event("filters_changed", _params, socket), do: {:noreply, socket}

  @impl true
  def handle_event("select_omnisearch_suggestion", %{"value" => value}, socket) do
    filters =
      socket.assigns.filters
      |> Map.put("query", combined_query(socket.assigns.filters["query"], value))
      |> reset_pagination()

    {:noreply, patch_filters(socket, filters)}
  end

  @impl true
  def handle_event("remove_omnisearch_token", %{"value" => value}, socket) do
    filters =
      socket.assigns.filters
      |> Map.put("query", remove_query_token(socket.assigns.filters["query"], value))
      |> reset_pagination()

    {:noreply, patch_filters(socket, filters)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <Shell.page
      page_title="Flags"
      page_kicker="Feature flags"
      page_summary="Operator view scoped to the current environment. Manage, debug, and govern all feature flags from here."
      current_environment={@current_environment}
      environments={@available_environments}
      env_links={@env_links}
      policy_state={@rulestead_admin_policy_state}
      base_path={@rulestead_admin_mount_path}
      current_section={:flags}
    >
      <FlagComponents.callout :if={@outcome_notice} title="Archive result" tone="positive">
        <p><%= @outcome_notice %></p>
        <p :if={@outcome_audit_path}>
          <a href={@outcome_audit_path}>Open audit timeline</a>
        </p>
      </FlagComponents.callout>

      <section class="rs-inventory">
        <header class="rs-section-header">
          <div>
            <p class="rs-eyebrow">Inventory route</p>
            <h2>Find the flag that needs review</h2>
            <p>
              Search, view tabs, result count, and pagination stay linked to the URL so operators can share the exact triage state.
            </p>
          </div>
        </header>

        <div
          :if={@rulestead_admin_policy_state.capabilities.edit? or @rulestead_admin_policy_state.capabilities.admin?}
          class="rs-inventory__toolbar"
        >
          <a
            href={@rulestead_admin_mount_path <> "/new?env=" <> @current_environment.key}
            class="rs-button rs-button--primary"
          >
            Create flag
          </a>
        </div>

        <form
          id="flag-filters-form"
          aria-label="Flag filters"
          phx-submit="filters_changed"
          class="rs-filter-panel"
          onsubmit="return false;"
        >
          <input type="hidden" name="filters[env]" value={@current_environment.key} />
          <input :if={@filters["view"] == "custom"} type="hidden" name="filters[lifecycle]" value={@filters["lifecycle"]} />
          <input :if={@filters["view"] == "custom"} type="hidden" name="filters[stale]" value={@filters["stale"]} />
          <input :if={@filters["view"] == "custom"} type="hidden" name="filters[readiness]" value={@filters["readiness"]} />
          <input :if={@filters["view"] == "custom"} type="hidden" name="filters[evidence_quality]" value={@filters["evidence_quality"]} />
          <input :if={@filters["view"] == "custom"} type="hidden" name="filters[include_archived]" value={@filters["include_archived"]} />
          <input type="hidden" name="filters[view]" value={@filters["view"]} />
          <input type="hidden" name="filters[sort]" value={@filters["sort"]} />
          <input type="hidden" name="filters[query]" value={@filters["query"]} />

          <div class="rs-filter-panel__header">
            <div class="rs-filter-panel__search rs-omnisearch">
              <label for="flag-omnisearch-input" class="sr-only">Search</label>
              <p class="rs-filter-panel__hint">
                First answer: filter by key, owner, tag, or description, then pick a view that explains why each result is here.
              </p>
              <div class="rs-omnisearch__control">
                <span
                  :for={token <- query_tokens(@filters["query"])}
                  class="rs-omnisearch__token"
                  data-token={token}
                >
                  <span :if={scoped_query_token?(token)} class="rs-omnisearch__token-scope">
                    <%= query_token_scope(token) %>
                  </span>
                  <span class="rs-omnisearch__token-value"><%= query_token_value(token) %></span>
                  <.link
                    patch={omnisearch_remove_token_path(assigns, token)}
                    aria-label={"Remove #{query_token_label(token)}"}
                    class="rs-omnisearch__token-remove"
                  >
                    <svg viewBox="0 0 16 16" aria-hidden="true">
                      <path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" />
                    </svg>
                  </.link>
                </span>
                <input id="flag-omnisearch-input" type="text" name="filters[query_text]" value={@omnisearch_input} placeholder="Search key, owner, tag, or description..." phx-change="omnisearch_changed" phx-debounce="300" />
              </div>
              <div :if={show_omnisearch_suggestions?(@omnisearch_input, @omnisearch_suggestions)} class="rs-omnisearch__menu" role="listbox" aria-label="Search suggestions">
                <div :if={@omnisearch_suggestions.flags != []} class="rs-omnisearch__group">
                  <p>Flags</p>
                  <.link
                    :for={flag <- @omnisearch_suggestions.flags}
                    patch={omnisearch_suggestion_path(assigns, "key", flag)}
                    role="option"
                    class="rs-omnisearch__option"
                  >
                    <span class="rs-omnisearch__option-scope">key</span>
                    <code><%= flag %></code>
                  </.link>
                </div>
                <div :if={@omnisearch_suggestions.owners != []} class="rs-omnisearch__group">
                  <p>Owners</p>
                  <.link
                    :for={owner <- @omnisearch_suggestions.owners}
                    patch={omnisearch_suggestion_path(assigns, "owner", owner)}
                    role="option"
                    class="rs-omnisearch__option"
                  >
                    <span class="rs-omnisearch__option-scope">owner</span>
                    <span><%= owner %></span>
                  </.link>
                </div>
                <div :if={@omnisearch_suggestions.tags != []} class="rs-omnisearch__group">
                  <p>Tags</p>
                  <.link
                    :for={tag <- @omnisearch_suggestions.tags}
                    patch={omnisearch_suggestion_path(assigns, "tag", tag)}
                    role="option"
                    class="rs-omnisearch__option"
                  >
                    <span class="rs-omnisearch__option-scope">tag</span>
                    <span><%= tag %></span>
                  </.link>
                </div>
              </div>
            </div>
          </div>

          <nav class="rs-inventory-views" aria-label="Flag inventory views">
            <.link
              :for={{view, label, _params} <- @inventory_views}
              patch={inventory_view_path(assigns, view)}
              data-current={to_string(@filters["view"] == view)}
              aria-current={if @filters["view"] == view, do: "page", else: nil}
            >
              <%= label %>
            </.link>
            <.link
              :if={@filters["view"] == "custom"}
              patch={build_index_path(@base_path, @filters)}
              data-current="true"
              aria-current="page"
            >
              Custom
            </.link>
          </nav>

          <span :if={@filters["view"] == "custom"} class="rs-filter-panel__hint">
            Custom view from URL filters
          </span>
        </form>

        <p :if={@error_message} role="alert"><%= @error_message %></p>

        <div class="rs-results-header">
          <div>
            <h3>
              Feature flags (<%= length(@page.entries) %><%= if @page.has_next_page?, do: "+", else: "" %>)
            </h3>
            <p :if={view_explanation(@filters["view"])} class="rs-results-header__hint">
              <%= view_explanation(@filters["view"]) %>
            </p>
          </div>
          <form class="rs-results-sort" aria-label="Sort flags" phx-change="filters_changed">
            <label>
              <span>Sort</span>
              <select name="filters[sort]">
                <option value="flag_key" selected={@filters["sort"] == "flag_key"}>Key A-Z</option>
                <option value="updated_at" selected={@filters["sort"] == "updated_at"}>Recently updated</option>
                <option value="inserted_at" selected={@filters["sort"] == "inserted_at"}>Newest first</option>
              </select>
            </label>
          </form>
        </div>

        <ul id="flags" phx-update="stream" aria-label="Feature flags list" class="rs-card-list">
          <li
            :for={{dom_id, entry} <- @streams.flags}
            id={dom_id}
            data-flag-key={entry.flag.key}
            data-highlighted={to_string(@highlighted_flag_key == entry.flag.key)}
            tabindex="0"
            class="rs-card rs-card--flag"
          >
            <div class="rs-card__header">
              <div class="rs-card__title-group">
                <a href={flag_path(assigns, entry.flag.key)} class="rs-card__title-link">
                  <code><%= entry.flag.key %></code>
                </a>
                <FlagComponents.environment_status status={entry.environment_status || entry.flag_environment.status || :draft} />
              </div>
              <div class="rs-card__actions">
                <%= if stale_state(entry.lifecycle) in [:stale, :potentially_stale] do %>
                  <a href={cleanup_path(assigns, entry.flag.key)}>
                    <FlagComponents.stale_badge state={stale_state(entry.lifecycle)} last_evaluated_at={entry.lifecycle.last_evaluated_at} />
                  </a>
                <% else %>
                  <FlagComponents.stale_badge state={stale_state(entry.lifecycle)} last_evaluated_at={entry.lifecycle.last_evaluated_at} />
                <% end %>
              </div>
            </div>

            <div class="rs-card__body">
              <p class="rs-card__description">
                <%= entry.flag.description || "No description provided." %>
              </p>
            </div>

            <.triage_note
              view={@filters["view"]}
              entry={entry}
              cleanup_path={cleanup_path(assigns, entry.flag.key)}
              timeline_path={timeline_path(assigns, entry.flag.key)}
            />

            <div class="rs-card__footer">
              <div class="rs-card__meta">
                <span class="rs-card__meta-item" data-meta="lifecycle" title="Lifecycle">
                  <.meta_icon name="lifecycle" />
                  <span class="sr-only">Lifecycle:</span>
                  <%= lifecycle_label(entry.lifecycle) %>
                </span>
                <span class="rs-card__meta-item" data-meta="owner" title="Owner">
                  <.meta_icon name="owner" />
                  <span class="sr-only">Owner:</span>
                  <%= entry.flag.ownership.owner_display || entry.flag.ownership.owner_ref %>
                </span>
                <span class="rs-card__meta-item" data-meta="type" title="Type">
                  <.meta_icon name="type" />
                  <span class="sr-only">Type:</span>
                  <%= humanize(entry.flag.flag_type) %>
                </span>
                <span class="rs-card__meta-item" title="Last changed">
                  <strong>Last changed:</strong>
                  <span title={format_last_changed_utc(entry.flag.updated_at || entry.flag.inserted_at)}>
                    <%= format_last_changed_relative(entry.flag.updated_at || entry.flag.inserted_at) %>
                  </span>
                </span>
              </div>
              <div class="rs-card__tags">
                <FlagComponents.tag_list tags={entry.flag.tags} />
              </div>
            </div>
          </li>
        </ul>

        <OperatorComponents.empty_state
          :if={Enum.empty?(@page.entries)}
          id="flags-empty"
          title="No flags found"
          body="Try adjusting your filters or search query, or create a new flag."
          icon="!"
          variant="hero"
        />

        <FlagComponents.pagination page={@page} base_path={@base_path} params={pagination_params(@filters)} />
      </section>
    </Shell.page>
    """
  end

  defp load_flags(socket, filters, suggestion_query \\ nil) do
    opts = list_opts(filters)
    suggestion_query = if is_nil(suggestion_query), do: filters["query"], else: suggestion_query

    case Rulestead.list_flags(opts) do
      {:ok, page} ->
        socket
        |> assign(:page, page)
        |> assign(:error_message, nil)
        |> assign_omnisearch_suggestions(filters, suggestion_query)
        |> stream(:flags, page.entries, reset: true)

      {:error, error} ->
        socket
        |> assign(:page, empty_page())
        |> assign(:error_message, error.message)
        |> assign(:omnisearch_suggestions, %{flags: [], owners: [], tags: []})
        |> stream(:flags, [], reset: true)
    end
  end

  defp assign_omnisearch_suggestions(socket, filters, suggestion_query) do
    suggestions =
      filters
      |> suggestion_opts()
      |> Rulestead.list_flags()
      |> case do
        {:ok, page} -> build_omnisearch_suggestions(page.entries, suggestion_query)
        {:error, _error} -> %{flags: [], owners: [], tags: []}
      end

    assign(socket, :omnisearch_suggestions, suggestions)
  end

  defp suggestion_opts(filters) do
    filters
    |> Map.put("query", "")
    |> Map.put("limit", "100")
    |> Map.put("after", nil)
    |> Map.put("before", nil)
    |> list_opts()
  end

  defp build_omnisearch_suggestions(entries, query) do
    %{
      flags:
        entries
        |> Enum.map(& &1.flag.key)
        |> matching_suggestions(query, 5),
      owners:
        entries
        |> Enum.flat_map(fn entry ->
          ownership = entry.flag.ownership || %{}
          [ownership.owner_ref, ownership.owner_display]
        end)
        |> matching_suggestions(query, 5),
      tags:
        entries
        |> Enum.flat_map(&(&1.flag.tags || []))
        |> matching_suggestions(query, 5)
    }
  end

  defp compact_sorted(values) do
    values
    |> Enum.reject(&(is_nil(&1) or &1 == ""))
    |> Enum.uniq()
    |> Enum.sort()
  end

  defp apply_transient_omnisearch(socket, input) do
    effective_filters =
      socket.assigns.filters
      |> Map.put("query", combined_query(socket.assigns.filters["query"], input))
      |> reset_pagination()

    socket
    |> assign(:omnisearch_input, input)
    |> load_flags(effective_filters, input)
  end

  defp omnisearch_input_from_params(%{"filters" => %{"query_text" => query_text}})
       when query_text != "",
       do: query_text

  defp omnisearch_input_from_params(%{"value" => value}) when is_binary(value), do: value
  defp omnisearch_input_from_params(%{"filters" => %{"query_text" => query_text}}), do: query_text
  defp omnisearch_input_from_params(_params), do: ""

  defp combined_query(committed_query, input) do
    [committed_query, input]
    |> Enum.flat_map(&query_tokens/1)
    |> compact_unique()
    |> Enum.join(" ")
  end

  defp scoped_query_token?(token) do
    match?(
      {scope, value} when scope in ["key", "owner", "tag"] and value != "",
      split_scoped_query_token(token)
    )
  end

  defp query_token_scope(token) do
    case split_scoped_query_token(token) do
      {scope, value} when scope in ["key", "owner", "tag"] and value != "" -> scope
      _other -> nil
    end
  end

  defp query_token_value(token) do
    case split_scoped_query_token(token) do
      {scope, value} when scope in ["key", "owner", "tag"] and value != "" -> value
      _other -> token
    end
  end

  defp query_token_label(token) do
    case split_scoped_query_token(token) do
      {scope, value} when scope in ["key", "owner", "tag"] and value != "" -> "#{scope}:#{value}"
      _other -> token
    end
  end

  defp scoped_query_value(scope, value) do
    scope = scope |> to_string() |> String.downcase()
    value = value |> to_string() |> String.trim()

    if scope in ["key", "owner", "tag"] and value != "" do
      "#{scope}:#{value}"
    else
      value
    end
  end

  defp split_scoped_query_token(token) when is_binary(token) do
    case String.split(token, ":", parts: 2) do
      [scope, value] -> {String.downcase(scope), value}
      _other -> nil
    end
  end

  defp split_scoped_query_token(_token), do: nil

  defp remove_query_token(query, token) do
    query
    |> query_tokens()
    |> Enum.reject(&(&1 == token))
    |> Enum.join(" ")
  end

  defp list_opts(filters) do
    [
      environment_key: filters["env"],
      query: blank_to_nil(filters["query"]),
      lifecycle: maybe_atom(filters["lifecycle"]),
      stale: maybe_atom(filters["stale"]),
      readiness: maybe_atom(filters["readiness"]),
      evidence_quality: maybe_atom(filters["evidence_quality"]),
      include_archived?: filters["include_archived"] == "true",
      limit: String.to_integer(filters["limit"] || Integer.to_string(@default_limit)),
      sort: maybe_atom(filters["sort"]),
      after: blank_to_nil(filters["after"]),
      before: blank_to_nil(filters["before"])
    ]
  end

  defp environment_links(base_path, filters, environments) do
    Enum.into(environments, %{}, fn environment ->
      env_filters =
        filters
        |> Map.put("env", environment.key)
        |> Map.put("after", nil)
        |> Map.put("before", nil)

      {environment.key, build_index_path(base_path, env_filters)}
    end)
  end

  defp pagination_params(filters) do
    filters
    |> Map.drop(["after", "before"])
    |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" or value == "false" end)
    |> Map.new()
  end

  defp patch_filters(socket, filters) do
    push_patch(socket,
      to:
        build_index_path(
          socket.assigns.base_path,
          normalize_filters(filters, socket.assigns.current_environment.key)
        )
    )
  end

  defp reset_pagination(filters) do
    filters
    |> Map.put("after", nil)
    |> Map.put("before", nil)
  end

  defp build_index_path(base_path, filters, extras \\ %{}) do
    query =
      filters
      |> ordered_query_params()
      |> Kernel.++(outcome_query_params(extras))
      |> Enum.reject(fn {_key, value} -> is_nil(value) or value == "" or value == "false" end)
      |> URI.encode_query()

    if query == "", do: base_path, else: base_path <> "?" <> query
  end

  defp ordered_query_params(filters) do
    [
      {"env", filters["env"]},
      {"view", filters["view"]},
      {"query", filters["query"]},
      {"lifecycle", custom_filter_param(filters, "lifecycle")},
      {"stale", custom_filter_param(filters, "stale")},
      {"readiness", custom_filter_param(filters, "readiness")},
      {"evidence_quality", custom_filter_param(filters, "evidence_quality")},
      {"include_archived", custom_filter_param(filters, "include_archived")},
      {"limit", serialize_limit(filters["limit"])},
      {"sort", serialize_sort(filters["sort"])},
      {"after", filters["after"]},
      {"before", filters["before"]}
    ]
  end

  defp normalize_filters(params, default_env) do
    params = stringify_keys(params)

    after_cursor = blank_to_nil(params["after"])
    before_cursor = blank_to_nil(params["before"])
    query = normalize_query(params)

    filters = %{
      "env" => blank_to_nil(params["env"]) || default_env,
      "query" => query,
      "view" => normalize_enum(params["view"], @allowed_views),
      "lifecycle" => normalize_enum(params["lifecycle"], @allowed_lifecycle),
      "stale" => normalize_enum(params["stale"], @allowed_stale),
      "readiness" => normalize_enum(params["readiness"], @allowed_readiness),
      "evidence_quality" => normalize_enum(params["evidence_quality"], @allowed_evidence_quality),
      "include_archived" => normalize_boolean(params["include_archived"]),
      "limit" => normalize_limit(params["limit"]),
      "sort" => normalize_sort(params["sort"]),
      "after" => if(before_cursor, do: nil, else: after_cursor),
      "before" => if(after_cursor, do: nil, else: before_cursor)
    }

    normalize_view_filters(filters, Map.has_key?(params, "view"))
  end

  defp stringify_keys(params) when is_map(params),
    do: Map.new(params, fn {key, value} -> {to_string(key), value} end)

  defp stringify_keys(_params), do: %{}

  defp normalize_query(params) do
    [
      params["query"],
      params["owner"],
      params["tags"]
    ]
    |> Enum.flat_map(&query_tokens/1)
    |> compact_unique()
    |> Enum.join(" ")
  end

  defp compact_unique(values) do
    Enum.reduce(values, [], fn value, acc ->
      cond do
        is_nil(value) or value == "" -> acc
        value in acc -> acc
        true -> acc ++ [value]
      end
    end)
  end

  defp query_tokens(value) when is_binary(value) do
    value
    |> String.split([",", " "])
    |> Enum.map(&String.trim/1)
    |> Enum.reject(&(&1 == ""))
  end

  defp query_tokens(value) when is_list(value), do: Enum.flat_map(value, &query_tokens/1)
  defp query_tokens(_value), do: []

  defp normalize_enum(value, allowed) when is_binary(value) do
    normalized = blank_to_nil(value)
    if normalized in allowed, do: normalized, else: ""
  end

  defp normalize_enum(_value, _allowed), do: ""

  defp normalize_boolean(value) when value in [true, "true", "on"], do: "true"
  defp normalize_boolean(_value), do: "false"

  defp normalize_limit(value) do
    value
    |> parse_integer()
    |> case do
      limit when limit in [10, 25, 50, 100] -> Integer.to_string(limit)
      _limit -> Integer.to_string(@default_limit)
    end
  end

  defp parse_integer(value) when is_integer(value), do: value

  defp parse_integer(value) when is_binary(value) do
    case Integer.parse(value) do
      {integer, ""} -> integer
      _other -> @default_limit
    end
  end

  defp parse_integer(_value), do: @default_limit

  defp serialize_limit(limit) do
    if limit == Integer.to_string(@default_limit), do: nil, else: limit
  end

  defp normalize_sort(sort) when sort in ["flag_key", "updated_at", "inserted_at"], do: sort
  defp normalize_sort(_sort), do: "flag_key"

  defp serialize_sort("flag_key"), do: nil
  defp serialize_sort(sort), do: sort

  defp custom_filter_param(%{"view" => "custom"} = filters, key), do: Map.get(filters, key)
  defp custom_filter_param(_filters, _key), do: nil

  defp maybe_atom(""), do: nil
  defp maybe_atom(nil), do: nil
  defp maybe_atom(value) when is_binary(value), do: String.to_atom(value)
  defp maybe_atom(value) when is_atom(value), do: value

  defp stale_state(%{state: state}) when state in [:potentially_stale, :stale], do: state
  defp stale_state(%{state: _state}), do: :fresh
  defp stale_state(_value), do: :fresh

  defp normalize_outcome_params(params) do
    params
    |> stringify_keys()
    |> Map.take(["notice", "flag_key", "reason", "audit_path", "highlight"])
    |> Enum.into(%{}, fn {key, value} -> {key, blank_to_nil(value)} end)
  end

  defp outcome_notice(%{"notice" => "archived", "flag_key" => flag_key}, environment_name)
       when is_binary(flag_key) do
    "Archived #{flag_key} in #{environment_name}. Review the audit timeline for the recorded reason."
  end

  defp outcome_notice(_outcome, _environment_name), do: nil

  defp normalize_audit_path(_socket, nil), do: nil

  defp normalize_audit_path(socket, path) do
    parsed = URI.parse(path)
    mount_path = socket.assigns.rulestead_admin_mount_path

    cond do
      parsed.scheme not in [nil, ""] -> nil
      parsed.host not in [nil, ""] -> nil
      parsed.path == mount_path -> path
      is_binary(parsed.path) and String.starts_with?(parsed.path, mount_path <> "/") -> path
      true -> nil
    end
  end

  defp default_filters do
    %{
      "env" => nil,
      "query" => "",
      "view" => "all",
      "lifecycle" => "",
      "stale" => "",
      "readiness" => "",
      "evidence_quality" => "",
      "include_archived" => "false",
      "limit" => Integer.to_string(@default_limit),
      "sort" => "flag_key",
      "after" => nil,
      "before" => nil
    }
  end

  defp empty_page do
    %Rulestead.Store.Command.Page{entries: [], limit: @default_limit}
  end

  defp normalize_view_filters(%{"view" => view} = filters, true)
       when view in ["all", "needs_review", "archive_candidates", "recently_stale", "archived"] do
    filters
    |> apply_view(view)
    |> Map.put("view", view)
  end

  defp normalize_view_filters(%{"view" => "custom"} = filters, true), do: filters

  defp normalize_view_filters(filters, _view_provided?) do
    case matching_inventory_view(filters) do
      nil -> Map.put(filters, "view", "custom")
      view -> filters |> apply_view(view) |> Map.put("view", view)
    end
  end

  defp apply_view(filters, view) do
    filters
    |> Map.merge(inventory_view_params(view))
    |> Map.put("after", nil)
    |> Map.put("before", nil)
  end

  defp matching_inventory_view(filters) do
    Enum.find_value(@inventory_views, fn {view, _label, params} ->
      if view_params_match?(filters, params), do: view
    end)
  end

  defp matching_suggestions(suggestions, query, limit) do
    query =
      query
      |> query_token_value()
      |> to_string()
      |> String.downcase()
      |> String.trim()

    suggestions
    |> compact_sorted()
    |> Enum.filter(fn suggestion ->
      query == "" or String.contains?(String.downcase(suggestion), query)
    end)
    |> Enum.take(limit)
  end

  defp show_omnisearch_suggestions?(query, suggestions) do
    query != "" and
      Enum.any?([suggestions.flags, suggestions.owners, suggestions.tags], &(&1 != []))
  end

  defp view_params_match?(filters, params) do
    Enum.all?(
      ["lifecycle", "stale", "readiness", "evidence_quality", "include_archived"],
      fn key ->
        Map.get(filters, key) == Map.get(params, key, "")
      end
    )
  end

  defp inventory_view_params(view) do
    @inventory_views
    |> Enum.find_value(%{}, fn
      {^view, _label, params} -> params
      _other -> nil
    end)
  end

  defp inventory_view_path(assigns, view) do
    filters =
      assigns.filters
      |> apply_view(view)
      |> Map.put("view", view)

    build_index_path(assigns.base_path, filters)
  end

  defp omnisearch_suggestion_path(assigns, scope, value) do
    scoped_value = scoped_query_value(scope, value)

    filters =
      assigns.filters
      |> Map.put("query", combined_query(assigns.filters["query"], scoped_value))
      |> reset_pagination()

    build_index_path(assigns.base_path, filters)
  end

  defp omnisearch_remove_token_path(assigns, token) do
    filters =
      assigns.filters
      |> Map.put("query", remove_query_token(assigns.filters["query"], token))
      |> reset_pagination()

    build_index_path(assigns.base_path, filters)
  end

  defp outcome_query_params(extras) do
    [
      {"notice", extras["notice"]},
      {"flag_key", extras["flag_key"]},
      {"reason", extras["reason"]},
      {"audit_path", extras["audit_path"]},
      {"highlight", extras["highlight"]}
    ]
  end

  defp flag_path(socket_or_assigns, key) do
    Session.path_with_return_to(
      socket_or_assigns,
      "#{socket_or_assigns.rulestead_admin_mount_path}/#{key}",
      socket_or_assigns.current_path
    )
  end

  defp cleanup_path(socket_or_assigns, key) do
    Session.path_with_return_to(
      socket_or_assigns,
      "#{socket_or_assigns.rulestead_admin_mount_path}/#{key}/cleanup",
      socket_or_assigns.current_path
    )
  end

  defp timeline_path(socket_or_assigns, key) do
    Session.path_with_return_to(
      socket_or_assigns,
      "#{socket_or_assigns.rulestead_admin_mount_path}/#{key}/timeline",
      socket_or_assigns.current_path
    )
  end

  attr(:view, :string, required: true)
  attr(:entry, :map, required: true)
  attr(:cleanup_path, :string, required: true)
  attr(:timeline_path, :string, required: true)

  defp triage_note(assigns) do
    assigns = assign(assigns, :summary, triage_summary(assigns.view, assigns.entry))

    ~H"""
    <div :if={@summary} class="rs-triage-note" data-tone={@summary.tone}>
      <div class="rs-triage-note__copy">
        <strong><%= @summary.title %></strong>
        <span><%= @summary.detail %></span>
      </div>
      <a :if={@summary.action == :cleanup} href={@cleanup_path}>Review cleanup</a>
      <a :if={@summary.action == :timeline} href={@timeline_path}>Open timeline</a>
    </div>
    """
  end

  defp triage_summary("needs_review", entry) do
    readiness = entry.lifecycle.archive_readiness

    detail =
      [
        first_reason_label(readiness),
        first_unknown_or_blocker_label(readiness),
        next_action_label(readiness)
      ]
      |> compact_sentence_parts()

    %{
      title: "Review needed",
      detail: detail || "Lifecycle evidence needs an operator decision.",
      tone: "warning",
      action: :cleanup
    }
  end

  defp triage_summary("archive_candidates", entry) do
    readiness = entry.lifecycle.archive_readiness

    detail =
      [
        first_reason_label(readiness),
        evidence_label(readiness.evidence_quality),
        next_action_label(readiness)
      ]
      |> compact_sentence_parts()

    %{
      title: "Ready to archive",
      detail: detail || "Strong cleanup evidence is available.",
      tone: "critical",
      action: :cleanup
    }
  end

  defp triage_summary("recently_stale", entry) do
    freshness = entry.lifecycle.freshness

    detail =
      [
        freshness_label(freshness.evaluation),
        last_evaluated_label(entry.lifecycle.last_evaluated_at),
        code_reference_label(freshness.code_references)
      ]
      |> compact_sentence_parts()

    %{
      title: "Stale signal",
      detail: detail || "Evaluation evidence is no longer current.",
      tone: "warning",
      action: :cleanup
    }
  end

  defp triage_summary("archived", _entry) do
    %{
      title: "Archived",
      detail: "Removed from active inventory; review the audit timeline for context.",
      tone: "muted",
      action: :timeline
    }
  end

  defp triage_summary(_view, _entry), do: nil

  defp view_explanation("needs_review"),
    do: "Flags with incomplete cleanup evidence, past review dates, or manual review required."

  defp view_explanation("archive_candidates"),
    do: "Flags with strong evidence that they can be cleaned up."

  defp view_explanation("recently_stale"),
    do: "Flags with stale evaluation or rollout activity."

  defp view_explanation("archived"),
    do: "Flags already removed from active runtime posture."

  defp view_explanation(_view), do: nil

  defp compact_sentence_parts(parts) do
    parts
    |> Enum.reject(&(is_nil(&1) or &1 == ""))
    |> Enum.uniq()
    |> case do
      [] -> nil
      values -> Enum.join(values, " · ")
    end
  end

  defp first_reason_label(%{reasons: reasons}) do
    reasons = List.wrap(reasons)

    [
      :no_code_refs,
      :review_horizon_passed,
      :stale_evaluation,
      :never_evaluated,
      :expiring_posture
    ]
    |> Enum.find(&(&1 in reasons))
    |> reason_label()
  end

  defp first_reason_label(_readiness), do: nil

  defp first_unknown_or_blocker_label(%{unknowns: [unknown | _]}), do: unknown_label(unknown)
  defp first_unknown_or_blocker_label(%{blockers: [blocker | _]}), do: blocker_label(blocker)
  defp first_unknown_or_blocker_label(_readiness), do: nil

  defp next_action_label(%{recommended_next_action: nil, secondary_actions: [action | _]}),
    do: action_label(action)

  defp next_action_label(%{recommended_next_action: action}), do: action_label(action)
  defp next_action_label(_readiness), do: nil

  defp evidence_label(:strong), do: "Strong evidence"
  defp evidence_label(:partial), do: "Partial evidence"
  defp evidence_label(:weak), do: "Evidence incomplete"
  defp evidence_label(_quality), do: nil

  defp freshness_label(:not_evaluated_recently), do: "No recent evaluations"
  defp freshness_label(:never_evaluated), do: "Never evaluated"
  defp freshness_label(:recently_evaluated), do: "Recently evaluated"
  defp freshness_label(_evaluation), do: nil

  defp last_evaluated_label(%DateTime{} = datetime),
    do: "Last evaluated #{format_last_changed_relative(datetime)}"

  defp last_evaluated_label(_datetime), do: nil

  defp code_reference_label(:fresh_refs_absent), do: "No code references found"
  defp code_reference_label(:refs_present), do: "Code references still present"
  defp code_reference_label(:scan_unknown), do: "Code-reference scan missing"
  defp code_reference_label(:scan_stale), do: "Code-reference scan stale"
  defp code_reference_label(_value), do: nil

  defp reason_label(:expiring_posture), do: "Expiring flag"
  defp reason_label(:review_horizon_passed), do: "Review date passed"
  defp reason_label(:stale_evaluation), do: "Stale evaluation"
  defp reason_label(:never_evaluated), do: "No evaluation yet"
  defp reason_label(:no_code_refs), do: "No code references found"
  defp reason_label(:already_archived), do: "Already archived"
  defp reason_label(nil), do: nil
  defp reason_label(reason), do: humanize(reason)

  defp unknown_label(:code_refs_scan_missing), do: "Refresh code refs"
  defp unknown_label(:code_refs_scan_stale), do: "Refresh code refs"
  defp unknown_label(:evaluation_missing), do: "Collect evaluation evidence"
  defp unknown_label(nil), do: nil
  defp unknown_label(unknown), do: humanize(unknown)

  defp blocker_label(:protected_flag_type), do: "Protected flag type"
  defp blocker_label(:permanent_posture), do: "Marked permanent"
  defp blocker_label(:remote_config_requires_review), do: "Remote config requires review"
  defp blocker_label(:code_refs_present), do: "Code references still present"
  defp blocker_label(:already_archived), do: "Already archived"
  defp blocker_label(nil), do: nil
  defp blocker_label(blocker), do: humanize(blocker)

  defp action_label(:archive_ready), do: "Archive ready"
  defp action_label(:keep_active), do: "Keep active"
  defp action_label(:review_manually), do: "Review manually"
  defp action_label(:refresh_code_refs), do: "Refresh code refs"
  defp action_label(:collect_eval_evidence), do: "Collect evaluation evidence"
  defp action_label(:remove_code_refs), do: "Remove code references"
  defp action_label(:mark_permanent), do: "Confirm permanent posture"
  defp action_label(nil), do: nil
  defp action_label(action), do: humanize(action)

  attr(:name, :string, required: true)

  defp meta_icon(assigns) do
    ~H"""
    <span class="rs-card__meta-icon" aria-hidden="true">
      <svg :if={@name == "lifecycle"} viewBox="0 0 20 20" fill="none">
        <path d="M4 10h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
        <path d="M10 6.75v6.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
        <circle cx="4" cy="10" r="1.35" fill="currentColor" />
        <circle cx="10" cy="10" r="1.35" fill="currentColor" />
        <circle cx="16" cy="10" r="1.35" fill="currentColor" />
      </svg>
      <svg :if={@name == "owner"} viewBox="0 0 20 20" fill="none">
        <path d="M10 10.15a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z" stroke="currentColor" stroke-width="1.6" />
        <path d="M4.5 16.35c.75-2.25 2.75-3.55 5.5-3.55s4.75 1.3 5.5 3.55" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
      </svg>
      <svg :if={@name == "type"} viewBox="0 0 20 20" fill="none">
        <path d="M4 6.5h12M4 13.5h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
        <path d="M7.5 8.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM12.5 15.5a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z" fill="currentColor" />
      </svg>
    </span>
    """
  end

  defp path_with_query(uri, fallback_path) do
    parsed = URI.parse(uri)

    if is_nil(parsed.query),
      do: parsed.path || fallback_path,
      else: parsed.path <> "?" <> parsed.query
  end

  defp query_params(uri) do
    uri
    |> URI.parse()
    |> Map.get(:query)
    |> case do
      nil -> %{}
      query -> URI.decode_query(query)
    end
  end

  defp blank_to_nil(nil), do: nil
  defp blank_to_nil(""), do: nil
  defp blank_to_nil(value), do: value

  defp humanize(value) when is_atom(value), do: humanize(to_string(value))

  defp humanize(value) when is_binary(value),
    do: value |> String.replace("_", " ") |> String.capitalize()

  defp humanize(value), do: to_string(value)

  defp lifecycle_label(%{mode: :expiring, review_by: %Date{} = review_by}),
    do: "Expires #{Calendar.strftime(review_by, "%b %-d, %Y")}"

  defp lifecycle_label(%{mode: :expiring}), do: "Expiring"
  defp lifecycle_label(%{mode: mode}), do: humanize(mode)
  defp lifecycle_label(value), do: humanize(value)

  defp format_last_changed_utc(nil), do: "Not recorded"

  defp format_last_changed_utc(%DateTime{} = datetime),
    do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")

  defp format_last_changed_utc(%NaiveDateTime{} = datetime),
    do: Calendar.strftime(datetime, "%Y-%m-%d %H:%M UTC")

  defp format_last_changed_utc(value), do: to_string(value)

  defp format_last_changed_relative(nil), do: "Unknown"

  defp format_last_changed_relative(%DateTime{} = datetime) do
    now = DateTime.utc_now()
    diff = DateTime.diff(now, datetime)
    relative_time(diff)
  end

  defp format_last_changed_relative(%NaiveDateTime{} = datetime) do
    now = NaiveDateTime.utc_now()
    diff = NaiveDateTime.diff(now, datetime)
    relative_time(diff)
  end

  defp format_last_changed_relative(value), do: to_string(value)

  defp relative_time(diff) when diff < 60, do: "just now"

  defp relative_time(diff) when diff < 3600 do
    mins = div(diff, 60)
    "#{mins} minute#{if mins == 1, do: "", else: "s"} ago"
  end

  defp relative_time(diff) when diff < 86400 do
    hours = div(diff, 3600)
    "#{hours} hour#{if hours == 1, do: "", else: "s"} ago"
  end

  defp relative_time(diff) when diff < 2_592_000 do
    days = div(diff, 86400)
    "#{days} day#{if days == 1, do: "", else: "s"} ago"
  end

  defp relative_time(diff) do
    months = div(diff, 2_592_000)
    "#{months} month#{if months == 1, do: "", else: "s"} ago"
  end
end