Skip to main content

lib/rulestead_admin/live/change_request_live/show.ex

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

  use Phoenix.LiveView

  alias Rulestead.Store.Command
  alias RulesteadAdmin.Components.GovernanceComponents
  alias RulesteadAdmin.Components.OperatorComponents
  alias RulesteadAdmin.Components.Shell
  alias RulesteadAdmin.Live.AudienceLive.Governance
  alias RulesteadAdmin.Live.AudienceLive.Shared
  alias RulesteadAdmin.Live.Session

  @impl true
  def mount(_params, _session, socket) do
    {:ok,
     socket
     |> assign(:page, nil)
     |> assign(:change_request_id, nil)
     |> assign(:change_request, nil)
     |> assign(:approvals, [])
     |> assign(:audit_events, [])
     |> assign(:pending_action, nil)
     |> assign(:action_notice, nil)
     |> assign(:action_error, nil)
     |> assign(:governance_metadata, %{})
     |> assign(:blast_radius_assessment, nil)
     |> assign(:approve_blocked_reason, nil)}
  end

  @impl true
  def handle_params(%{"id" => id} = params, uri, socket) do
    params = Map.merge(params, query_params(uri))
    base_path = "#{index_path(socket)}/#{id}"
    socket = apply_resolved(socket, params)

    if params["env"] != socket.assigns.current_environment.key do
      {:noreply, push_patch(socket, to: Session.current_path(socket, base_path))}
    else
      {change_request, approvals, audit_events, error_message} = load_change_request(id)

      page =
        socket
        |> build_page(change_request, id)
        |> Map.put(:error_message, error_message)

      {:noreply,
       socket
       |> assign(:change_request_id, id)
       |> assign(:change_request, change_request)
       |> assign(:approvals, approvals)
       |> assign(:audit_events, audit_events)
       |> assign(:pending_action, nil)
       |> assign(:action_notice, nil)
       |> assign(:action_error, nil)
       |> assign_governance_review(change_request)
       |> assign(:page, page)}
    end
  end

  @impl true
  def handle_event("start_action", %{"action" => action}, socket) do
    {:noreply, socket |> assign(:pending_action, action) |> assign(:action_error, nil)}
  end

  @impl true
  def handle_event("cancel_action", _params, socket) do
    {:noreply, socket |> assign(:pending_action, nil) |> assign(:action_error, nil)}
  end

  @impl true
  def handle_event("submit_action", %{"action" => params}, socket) do
    reason = normalize_reason(Map.get(params, "reason"))

    cond do
      is_nil(socket.assigns.pending_action) ->
        {:noreply,
         assign(socket, :action_error, "Choose an action before submitting confirmation")}

      is_nil(reason) ->
        {:noreply, assign(socket, :action_error, "Enter a reason before continuing")}

      true ->
        submit_action(socket, socket.assigns.pending_action, params, reason)
    end
  end

  @impl true
  def render(%{page: page} = assigns) when is_map(page) do
    assigns = assign(assigns, :page, page)

    ~H"""
    <Shell.page
      page_title={@page.page_title}
      page_kicker={@page.page_kicker}
      page_summary={@page.page_summary}
      current_environment={@page.current_environment}
      environments={@page.environments}
      env_links={@page.env_links}
      current_tenant={@page.current_tenant}
      tenants={@page.tenants}
      tenant_links={@page.tenant_links}
      base_path={@rulestead_admin_mount_path}
      current_section={:change_requests}
      breadcrumbs={[
        %{label: "Change requests", path: @rulestead_admin_mount_path <> "/change-requests?env=" <> @page.current_environment.key},
        %{label: "Request " <> @change_request_id, path: @rulestead_admin_mount_path <> "/change-requests/" <> @change_request_id <> "?env=" <> @page.current_environment.key}
      ]}
      policy_state={@page.policy_state}
    >
      <p :if={@page.error_message} role="alert"><%= @page.error_message %></p>

      <section :if={@change_request} class="rs-card">
        <h2>Proposed change</h2>
        <p><strong><%= diff_title(@change_request) %></strong></p>
        <p><%= diff_summary(@change_request) %></p>
      </section>

      <section :if={@blast_radius_assessment}>
        <GovernanceComponents.blast_radius_panel
          assessment={@blast_radius_assessment}
          variant={:reviewer}
          visibility={:full}
          frozen?={true}
        />
      </section>

      <section :if={@change_request} class="rs-card">
        <h2>Review context</h2>
        <p class="hidden">requested by <%= actor_name(@change_request.submitted_by) %></p>
        <p class="hidden">Status: <%= humanize(@change_request.state) %></p>
        <p class="hidden">Required approvals: <%= @change_request.approval_requirement.required_approvals %></p>
        <OperatorComponents.detail_grid rows={review_context_rows(@change_request, @approvals)} />
      </section>

      <section :if={@change_request} class="rs-card">
        <h2>Simulation and audit context</h2>
        <p>Diff and operator state stay above the fold; simulation and audit context explain why this request is safe to continue.</p>
        <p :if={@audit_events != []}>Audit state: <%= latest_audit_state(@audit_events) %></p>
        <p :if={@audit_events == []}>No audit events have been recorded for this request yet.</p>
      </section>

      <section :if={@change_request} class="rs-card">
        <h2>Review actions</h2>
        <p>Preview, confirm, and audit are kept separate so reviewers can see exactly what will happen before mutation.</p>
        <p :if={@action_notice} role="status"><%= @action_notice %></p>
        <p :if={@action_error} role="alert"><%= @action_error %></p>

        <OperatorComponents.capability_explanation
          :if={
            @change_request.state == :submitted and not is_nil(@approve_blocked_reason) and
              (@rulestead_admin_policy_state.capabilities.execute? or
                 @rulestead_admin_policy_state.capabilities.admin?)
          }
          title="Broader flag read access required to approve this change."
          reason={@approve_blocked_reason}
        />

        <div :if={is_nil(@pending_action) and (@rulestead_admin_policy_state.capabilities.execute? or @rulestead_admin_policy_state.capabilities.admin?)} class="rs-detail__actions">
          <button
            :if={@change_request.state == :submitted and is_nil(@approve_blocked_reason)}
            type="button"
            phx-click="start_action"
            phx-value-action="approve"
          >
            Approve
          </button>
          <button
            :if={@change_request.state == :submitted}
            type="button"
            phx-click="start_action"
            phx-value-action="reject"
          >
            Reject
          </button>
          <button
            :if={@change_request.state == :approved}
            type="button"
            phx-click="start_action"
            phx-value-action="execute"
          >
            Execute now
          </button>
          <button
            :if={@change_request.state == :approved}
            type="button"
            phx-click="start_action"
            phx-value-action="schedule"
          >
            Schedule
          </button>
        </div>

        <div :if={is_nil(@pending_action) and not @rulestead_admin_policy_state.capabilities.execute? and not @rulestead_admin_policy_state.capabilities.admin?} class="rs-actions-disabled">
          <RulesteadAdmin.Components.OperatorComponents.capability_explanation
            title="Execution required"
            reason="You do not have permission to execute or approve change requests."
          />
        </div>

        <form
          :if={not is_nil(@pending_action) and (@rulestead_admin_policy_state.capabilities.execute? or @rulestead_admin_policy_state.capabilities.admin?)}
          id="change-request-action-form"
          phx-submit="submit_action"
          class="rs-inline-action-form"
        >
          <p>Confirm <%= humanize(@pending_action) %> before mutation.</p>
          <label>
            <span>Reason</span>
            <input type="text" name="action[reason]" value="" />
          </label>
          <label :if={@pending_action == "schedule"}>
            <span>Scheduled for</span>
            <input type="datetime-local" name="action[scheduled_for]" value="2026-04-25T16:00" />
          </label>
          <button type="submit">Confirm <%= humanize(@pending_action) %></button>
          <button type="button" phx-click="cancel_action">Back to preview</button>
        </form>
      </section>

      <section class="rs-page-section">
        <h2>Related routes</h2>
        <OperatorComponents.related_links links={related_links(@page)} />
      </section>
    </Shell.page>
    """
  end

  defp index_path(socket), do: "#{mount_path(socket)}/change-requests"
  defp schedule_path(socket), do: "#{mount_path(socket)}/schedule"
  defp audit_path(socket), do: "#{mount_path(socket)}/audit"

  defp mount_path(socket), do: socket.assigns.rulestead_admin_mount_path

  defp build_page(socket, change_request, id, scheduled_execution_id \\ nil) do
    base_path = "#{index_path(socket)}/#{id}"

    socket.assigns
    |> Session.placeholder_assigns(
      current_path: base_path,
      page_title: "Change request review",
      page_kicker: "Governance",
      page_summary:
        "Dedicated review route for proposed changes, approval state, and explicit next-step execution decisions."
    )
    |> Map.merge(%{
      queue_path: Session.current_path(socket, index_path(socket)),
      schedule_path: Session.current_path(socket, schedule_path(socket)),
      audit_path: Session.current_path(socket, audit_path(socket)),
      request_id: id,
      error_message: nil,
      flag_path:
        if(change_request && change_request.resource_key,
          do: Session.current_path(socket, "#{mount_path(socket)}/#{change_request.resource_key}")
        ),
      scheduled_execution_path:
        if(scheduled_execution_id,
          do: Session.current_path(socket, "#{schedule_path(socket)}/#{scheduled_execution_id}")
        ),
      webhooks_path: Session.current_path(socket, "#{mount_path(socket)}/webhooks")
    })
  end

  defp review_context_rows(change_request, approvals) do
    [
      %{label: "Status", value: humanize(change_request.state)},
      %{label: "Action", value: humanize(change_request.action)},
      %{label: "Resource", value: change_request.resource_key},
      %{label: "Environment", value: change_request.environment_key},
      %{label: "requested by", value: actor_name(change_request.submitted_by)},
      %{
        label: "Required approvals",
        value: to_string(change_request.approval_requirement.required_approvals)
      },
      %{
        label: "Approved by",
        value: if(approvals == [], do: "No approvals yet", else: joined_reviewers(approvals))
      }
    ]
  end

  defp related_links(page) do
    [
      %{label: "Back to change requests", path: page.queue_path},
      %{label: "Open schedule", path: page.schedule_path},
      %{label: "Open audit timeline", path: page.audit_path},
      if(page.flag_path, do: %{label: "Open flag", path: page.flag_path}),
      if(page.scheduled_execution_path,
        do: %{label: "Open scheduled execution", path: page.scheduled_execution_path}
      ),
      if(page.webhooks_path, do: %{label: "Open webhooks", path: page.webhooks_path})
    ]
    |> Enum.reject(&is_nil/1)
  end

  defp load_change_request(id) do
    case Rulestead.fetch_change_request(Command.FetchChangeRequest.new(id)) do
      {:ok, %{change_request: change_request, approvals: approvals, audit_events: audit_events}} ->
        {change_request, approvals, audit_events, nil}

      {:error, error} ->
        {nil, [], [], error.message}
    end
  end

  defp submit_action(socket, "approve", _params, reason) do
    command =
      Command.ApproveChangeRequest.new(socket.assigns.change_request_id,
        actor: socket.assigns.current_actor,
        reason: reason,
        metadata: %{source: :admin_ui}
      )

    mutate_change_request(
      socket,
      &Rulestead.approve_change_request/1,
      command,
      "Change request approved."
    )
  end

  defp submit_action(socket, "reject", _params, reason) do
    command =
      Command.RejectChangeRequest.new(socket.assigns.change_request_id,
        actor: socket.assigns.current_actor,
        reason: reason,
        metadata: %{source: :admin_ui}
      )

    mutate_change_request(
      socket,
      &Rulestead.reject_change_request/1,
      command,
      "Change request rejected."
    )
  end

  defp submit_action(socket, "execute", _params, reason) do
    command =
      Command.ExecuteChangeRequest.new(socket.assigns.change_request_id,
        actor: socket.assigns.current_actor,
        reason: reason,
        metadata: %{source: :admin_ui}
      )

    mutate_change_request(
      socket,
      &Rulestead.execute_change_request/1,
      command,
      "Change request executed."
    )
  end

  defp submit_action(socket, "schedule", params, reason) do
    with {:ok, scheduled_for} <- parse_scheduled_for(Map.get(params, "scheduled_for")) do
      command =
        Command.ScheduleChangeRequest.new(%{
          change_request_id: socket.assigns.change_request_id,
          scheduled_for: scheduled_for,
          actor: socket.assigns.current_actor,
          reason: reason,
          metadata: %{source: :admin_ui}
        })

      case Rulestead.schedule_change_request(command) do
        {:ok, %{scheduled_execution: scheduled_execution}} ->
          {change_request, approvals, audit_events, _error} =
            load_change_request(socket.assigns.change_request_id)

          {:noreply,
           socket
           |> assign(:change_request, change_request)
           |> assign(:approvals, approvals)
           |> assign(:audit_events, audit_events)
           |> assign(:pending_action, nil)
           |> assign(:action_error, nil)
           |> assign(
             :action_notice,
             "Change request scheduled. Audit state: #{latest_audit_state(audit_events)}"
           )
           |> assign(
             :page,
             build_page(socket, change_request, change_request.id, scheduled_execution.id)
           )}

        {:error, error} ->
          {:noreply, assign(socket, :action_error, error.message)}
      end
    else
      {:error, message} -> {:noreply, assign(socket, :action_error, message)}
    end
  end

  defp submit_action(socket, _action, _params, _reason) do
    {:noreply, assign(socket, :action_error, "Unsupported review action")}
  end

  defp mutate_change_request(socket, operation, command, notice) do
    case operation.(command) do
      {:ok, %{change_request: change_request}} ->
        {_reloaded_change_request, approvals, audit_events, _error} =
          load_change_request(change_request.id)

        {:noreply,
         socket
         |> assign(:change_request, change_request)
         |> assign(:approvals, approvals)
         |> assign(:audit_events, audit_events)
         |> assign(:pending_action, nil)
         |> assign(:action_error, nil)
         |> assign(:action_notice, "#{notice} Audit state: #{latest_audit_state(audit_events)}")
         |> assign(:page, build_page(socket, change_request, change_request.id))}

      {:error, error} ->
        {:noreply, assign(socket, :action_error, error.message)}
    end
  end

  defp diff_title(%{action: :apply_audience_mutation} = change_request) do
    operation = command_field(change_request.command, "operation")
    audience_key = command_field(change_request.command, "audience_key")

    "Audience #{humanize(operation)} ยท #{audience_key || change_request.resource_key}"
  end

  defp diff_title(change_request) do
    get_in(change_request.command, ["diff", "title"]) || "Governed mutation preview"
  end

  defp diff_summary(%{action: :apply_audience_mutation} = change_request) do
    operation = command_field(change_request.command, "operation")
    audience_key = command_field(change_request.command, "audience_key")
    environment = change_request.environment_key

    "Proposed #{humanize(operation)} for audience #{audience_key || change_request.resource_key} in #{environment}."
  end

  defp diff_summary(change_request) do
    get_in(change_request.command, ["diff", "summary"]) || "No diff summary was recorded."
  end

  defp assign_governance_review(socket, %{action: :apply_audience_mutation} = change_request) do
    metadata = change_request.metadata || %{}
    assessment = Map.get(metadata, "blast_radius_assessment")
    tier = audience_visibility_tier(socket, change_request.resource_key)

    approve_blocked_reason =
      if tier != :full do
        "At least one affected reference is hidden by your permissions. Broader flag read access is required to approve this change."
      end

    socket
    |> assign(:governance_metadata, metadata)
    |> assign(:blast_radius_assessment, assessment)
    |> assign(:approve_blocked_reason, approve_blocked_reason)
  end

  defp assign_governance_review(socket, _change_request) do
    socket
    |> assign(:governance_metadata, %{})
    |> assign(:blast_radius_assessment, nil)
    |> assign(:approve_blocked_reason, nil)
  end

  defp audience_visibility_tier(socket, audience_key) when is_binary(audience_key) do
    deps_result =
      Rulestead.list_audience_dependencies(Shared.dependency_command(socket, audience_key))

    inventory = normalize_dependency_inventory(deps_result)
    Governance.visibility_tier(inventory)
  end

  defp audience_visibility_tier(_socket, _audience_key), do: :full

  defp normalize_dependency_inventory({:ok, result}) do
    %{
      summary: Shared.dependency_summary(result),
      entries: Map.get(result, :entries, []),
      redacted_entries: Map.get(result, :redacted_entries, []),
      hidden_count: Map.get(result, :hidden_reference_count, 0),
      denied?: false
    }
  end

  defp normalize_dependency_inventory({:error, error}) do
    if auth_error?(error) do
      %{
        summary: "Dependency list unavailable",
        entries: [],
        redacted_entries: [],
        hidden_count: 0,
        denied?: true
      }
    else
      %{
        summary: "Dependency list unavailable",
        entries: [],
        redacted_entries: [],
        hidden_count: 0,
        denied?: false
      }
    end
  end

  defp auth_error?(%{domain: :auth}), do: true
  defp auth_error?(%{domain: "auth"}), do: true
  defp auth_error?(_), do: false

  defp command_field(command, key) when is_map(command) do
    Map.get(command, key) || Map.get(command, String.to_atom(key))
  end

  defp command_field(_command, _key), do: nil

  defp actor_name(actor) when is_map(actor),
    do: actor[:display] || actor["display"] || actor[:id] || actor["id"] || "Unknown operator"

  defp actor_name(_actor), do: "Unknown operator"

  defp joined_reviewers(approvals),
    do: Enum.map_join(approvals, ", ", &actor_name(&1.reviewed_by))

  defp latest_audit_state([event | _]), do: event.event_type
  defp latest_audit_state([]), do: "pending"

  defp parse_scheduled_for(nil), do: {:error, "Choose when the change should run"}
  defp parse_scheduled_for(""), do: {:error, "Choose when the change should run"}

  defp parse_scheduled_for(value) do
    case NaiveDateTime.from_iso8601(value <> ":00") do
      {:ok, naive} -> {:ok, DateTime.from_naive!(naive, "Etc/UTC")}
      _ -> {:error, "Choose a valid schedule time"}
    end
  end

  defp normalize_reason(nil), do: nil
  defp normalize_reason(""), do: nil
  defp normalize_reason(reason), do: String.trim(reason)

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

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

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

  defp apply_resolved(socket, params) do
    resolved =
      Session.resolve(
        params,
        %{
          "current_actor" => socket.assigns.current_actor,
          "rulestead_admin_environments" => socket.assigns.available_environments,
          "rulestead_admin_last_env" => socket.assigns.current_environment.key
        },
        policy: socket.assigns.rulestead_admin_policy,
        mount_path: socket.assigns.rulestead_admin_mount_path
      )

    socket
    |> assign(:current_environment, resolved.environment)
    |> assign(:available_environments, resolved.environments)
    |> assign(:rulestead_admin_env_source, resolved.env_source)
    |> assign(:rulestead_admin_policy_state, Session.policy_state(resolved))
    |> assign(:rulestead_admin_session, resolved)
  end

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