defmodule Mix.Tasks.Wnh.DbScaleBenchmark do
  use Mix.Task

  alias WhoNeedHelp.Repo

  @shortdoc "Measures keyset pagination plans on an isolated large data set"

  @moduledoc """
  Seeds and measures the isolated database used by `scripts/db-scale-benchmark.sh`.

  The task records PostgreSQL `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` output.
  It intentionally does not enforce an execution-time threshold because timings
  depend on the host. The `after` phase does enforce query correctness and that
  each measured cursor query uses its purpose-built index.
  """

  @default_rows 50_000

  @impl Mix.Task
  def run(arguments) do
    Mix.Task.run("app.start")

    {options, _rest, invalid} =
      OptionParser.parse(arguments,
        strict: [phase: :string, rows: :integer, output: :string]
      )

    if invalid != [], do: Mix.raise("invalid options: #{inspect(invalid)}")

    phase = Keyword.get(options, :phase) || Mix.raise("--phase before|after is required")
    rows = Keyword.get(options, :rows, @default_rows)
    output = Keyword.get(options, :output) || Mix.raise("--output PATH is required")

    unless phase in ["before", "after"], do: Mix.raise("--phase must be before or after")
    unless rows >= 3, do: Mix.raise("--rows must be at least 3 for relational fixtures")

    File.mkdir_p!(output)

    if phase == "before" do
      seed!(rows)
    else
      assert_seed_size!(rows)
    end

    analyze!()
    plans = collect_plans!(phase, output)
    assert_keyset_pages!()

    summary = %{
      phase: phase,
      configured_rows_per_large_table: rows,
      captured_at: DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601(),
      postgres_version: scalar!("SHOW server_version"),
      table_counts: table_counts(),
      database_bytes: scalar!("SELECT pg_database_size(current_database())"),
      index_bytes: index_sizes(),
      plans: plans
    }

    summary_path = Path.join(output, "#{phase}-summary.json")
    File.write!(summary_path, Jason.encode_to_iodata!(summary, pretty: true))

    if phase == "after" do
      assert_expected_indexes_exist!()

      if rows == @default_rows do
        assert_expected_indexes_used!(plans)
      end

      write_comparison!(output, summary)
    end

    Mix.shell().info("database scale #{phase} phase written to #{summary_path}")
  end

  defp seed!(rows) do
    Repo.transaction(
      fn ->
        Enum.each(seed_statements(rows), &query!/1)
      end,
      timeout: :infinity
    )
  end

  defp seed_statements(rows) do
    [
      """
      INSERT INTO users (
        id, email, display_name, confirmed_at, accepted_terms_at, inserted_at, updated_at
      )
      SELECT
        md5('user-' || value)::uuid,
        'db-scale-' || value || '@example.invalid',
        'DB scale user ' || value,
        date_trunc('second', now()),
        date_trunc('second', now()),
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO categories (
        id, slug, names, descriptions, mode, inserted_at, updated_at
      )
      VALUES (
        md5('db-scale-category')::uuid,
        'db-scale-help',
        '{"en":"DB scale help"}'::jsonb,
        '{"en":"Synthetic isolated benchmark category"}'::jsonb,
        'help',
        date_trunc('second', now()),
        date_trunc('second', now())
      )
      """,
      """
      INSERT INTO help_requests (
        id, title, description, location_label, location, status, urgency,
        location_visibility, expires_at, hidden_at, requester_id, category_id,
        inserted_at, updated_at
      )
      SELECT
        md5('request-' || value)::uuid,
        'DB scale request ' || value,
        'Synthetic isolated database scale request ' || value,
        'Kyiv benchmark point',
        ST_SetSRID(ST_MakePoint(30.5 + (value % 100) / 10000.0, 50.4), 4326),
        CASE WHEN value % 4 = 0 THEN 'open' ELSE 'completed' END,
        'now',
        'approximate_public',
        date_trunc('second', now()) + (value % 30 + 1) * interval '1 day',
        CASE WHEN value % 20 = 0 THEN date_trunc('second', now()) END,
        md5('user-1')::uuid,
        md5('db-scale-category')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO help_assignments (
        id, status, handover_code_hash, handover_verified_at,
        proximity_observed_at, helper_movement_observed_at, accepted_at,
        completed_at, request_id, helper_id, inserted_at, updated_at
      )
      SELECT
        md5('assignment-' || value)::uuid,
        'completed',
        decode(md5('handover-' || value), 'hex'),
        CASE WHEN value % 2 = 0 THEN date_trunc('second', now()) END,
        CASE WHEN value % 3 = 0 THEN date_trunc('second', now()) END,
        CASE WHEN value % 4 = 0 THEN date_trunc('second', now()) END,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()),
        md5('request-' || value)::uuid,
        md5('user-2')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now())
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO messages (
        id, body, assignment_id, sender_id, inserted_at, updated_at
      )
      SELECT
        md5('message-' || value)::uuid,
        'Synthetic isolated message ' || value,
        md5('assignment-1')::uuid,
        md5('user-' || CASE WHEN value % 2 = 0 THEN 1 ELSE 2 END)::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO reviews (
        id, rating, comment, revealed_at, assignment_id, reviewer_id, reviewee_id,
        inserted_at, updated_at
      )
      SELECT
        md5('review-' || value)::uuid,
        value % 5 + 1,
        'Synthetic isolated review ' || value,
        date_trunc('second', now()),
        md5('assignment-' || value)::uuid,
        md5('user-1')::uuid,
        md5('user-2')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO reports (
        id, reason, details, status, reporter_id, request_id, inserted_at, updated_at
      )
      SELECT
        md5('report-' || value)::uuid,
        'safety',
        'Synthetic isolated report ' || value,
        CASE
          WHEN value % 4 = 0 THEN 'open'
          WHEN value % 4 = 1 THEN 'reviewing'
          WHEN value % 4 = 2 THEN 'resolved'
          ELSE 'dismissed'
        END,
        md5('user-2')::uuid,
        md5('request-' || value)::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO blocks (id, blocker_id, blocked_id, inserted_at)
      SELECT
        md5('block-' || value)::uuid,
        md5('user-2')::uuid,
        md5('user-' || value)::uuid,
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(3, #{rows}) AS value
      """,
      """
      INSERT INTO abuse_signals (
        id, kind, status, metadata, subject_id, inserted_at, updated_at
      )
      SELECT
        md5('signal-' || value)::uuid,
        'db_scale_' || value,
        CASE
          WHEN value % 3 = 0 THEN 'open'
          WHEN value % 3 = 1 THEN 'reviewed'
          ELSE 'dismissed'
        END,
        '{}'::jsonb,
        md5('user-' || value)::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO category_proposals (
        id, proposed_name, reason, status, proposer_id, mode, inserted_at, updated_at
      )
      SELECT
        md5('proposal-' || value)::uuid,
        'DB scale proposal ' || value,
        'Synthetic isolated category proposal ' || value,
        CASE
          WHEN value % 4 = 0 THEN 'open'
          WHEN value % 4 = 1 THEN 'approved'
          WHEN value % 4 = 2 THEN 'rejected'
          ELSE 'merged'
        END,
        md5('user-1')::uuid,
        'help',
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO activities (
        id, title, description, location_label, location, status, starts_at,
        join_deadline, capacity, hidden_at, creator_id, category_id, inserted_at, updated_at
      )
      SELECT
        md5('activity-' || value)::uuid,
        'DB scale activity ' || value,
        'Synthetic isolated activity ' || value,
        'Kyiv benchmark point',
        ST_SetSRID(ST_MakePoint(30.5, 50.4 + (value % 100) / 10000.0), 4326),
        CASE
          WHEN value % 3 = 0 THEN 'open'
          WHEN value % 3 = 1 THEN 'completed'
          ELSE 'cancelled'
        END,
        date_trunc('second', now()) + (value + 1000) * interval '1 second',
        date_trunc('second', now()) + (value + 700) * interval '1 second',
        10,
        CASE WHEN value % 30 = 0 THEN date_trunc('second', now()) END,
        md5('user-1')::uuid,
        md5('db-scale-category')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """,
      """
      INSERT INTO activity_participants (
        id, role, status, reviewed_at, activity_id, user_id, inserted_at, updated_at
      )
      SELECT
        md5('participant-' || value)::uuid,
        'participant',
        'approved',
        date_trunc('second', now()),
        md5('activity-' || value)::uuid,
        md5('user-' || (value % 100 + 3))::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      UNION ALL
      SELECT
        md5('participant-user-2-' || value)::uuid,
        'participant',
        'approved',
        date_trunc('second', now()),
        md5('activity-' || value)::uuid,
        md5('user-2')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(100, #{rows}, 100) AS value
      """,
      """
      INSERT INTO activity_messages (
        id, body, activity_id, sender_id, inserted_at, updated_at
      )
      SELECT
        md5('activity-message-' || value)::uuid,
        'Synthetic isolated activity message ' || value,
        md5('activity-1')::uuid,
        md5('user-1')::uuid,
        date_trunc('second', now()) - value * interval '1 second',
        date_trunc('second', now()) - value * interval '1 second'
      FROM generate_series(1, #{rows}) AS value
      """
    ]
  end

  defp analyze! do
    query!("""
    ANALYZE users, help_requests, help_assignments, messages, reviews, reports,
      blocks, abuse_signals, category_proposals, activities,
      activity_participants, activity_messages
    """)
  end

  defp collect_plans!(phase, output) do
    plan_queries()
    |> Map.new(fn {name, query} ->
      plan = explain!(query)

      File.write!(
        Path.join(output, "#{phase}-#{name}.json"),
        Jason.encode_to_iodata!(plan, pretty: true)
      )

      {name, summarize_plan(plan)}
    end)
  end

  defp plan_queries do
    viewer = "md5('user-1')::uuid"

    %{
      "help_discovery" => """
      SELECT id FROM help_requests
      WHERE status = 'open' AND hidden_at IS NULL AND expires_at > now()
        AND requester_id NOT IN (
          SELECT blocked_id FROM blocks WHERE blocker_id = #{viewer}
        )
        AND requester_id NOT IN (
          SELECT blocker_id FROM blocks WHERE blocked_id = #{viewer}
        )
      ORDER BY expires_at ASC, id ASC LIMIT 25
      """,
      "help_requester" => """
      SELECT id FROM help_requests
      WHERE requester_id = md5('user-1')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "activity_discovery" => """
      SELECT id FROM activities
      WHERE status = 'open' AND hidden_at IS NULL
        AND starts_at > now() AND join_deadline > now()
      ORDER BY starts_at ASC, id ASC LIMIT 25
      """,
      "activity_participant" => """
      SELECT activities.id
      FROM activities
      INNER JOIN activity_participants
        ON activity_participants.activity_id = activities.id
       AND activity_participants.user_id = md5('user-2')::uuid
       AND activity_participants.status IN ('requested', 'approved')
      WHERE activities.creator_id NOT IN (
        SELECT blocked_id FROM blocks WHERE blocker_id = md5('user-2')::uuid
      )
        AND activities.creator_id NOT IN (
          SELECT blocker_id FROM blocks WHERE blocked_id = md5('user-2')::uuid
        )
      ORDER BY activities.starts_at DESC, activities.id DESC LIMIT 25
      """,
      "messages" => """
      SELECT id FROM messages
      WHERE assignment_id = md5('assignment-1')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 51
      """,
      "activity_messages" => """
      SELECT id FROM activity_messages
      WHERE activity_id = md5('activity-1')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 51
      """,
      "visible_reviews" => """
      SELECT id FROM reviews
      WHERE reviewee_id = md5('user-2')::uuid AND revealed_at IS NOT NULL
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "reports_open" => """
      SELECT id FROM reports
      WHERE status = 'open'
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "reports_all" => """
      SELECT id FROM reports
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "blocks" => """
      SELECT id FROM blocks
      WHERE blocker_id = md5('user-2')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "abuse_signals" => """
      SELECT id FROM abuse_signals
      WHERE status = 'open'
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "proposals_open" => """
      SELECT id FROM category_proposals
      WHERE status = 'open'
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "proposals_all" => """
      SELECT id FROM category_proposals
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "users" => """
      SELECT id FROM users
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """,
      "reputation_helper" => """
      SELECT
        count(help_assignments.id),
        count(DISTINCT help_requests.requester_id),
        count(help_assignments.id) FILTER (
          WHERE help_assignments.handover_verified_at IS NOT NULL
        ),
        count(help_assignments.id) FILTER (
          WHERE help_assignments.proximity_observed_at IS NOT NULL
            AND help_assignments.helper_movement_observed_at IS NOT NULL
        )
      FROM help_assignments
      INNER JOIN help_requests
        ON help_requests.id = help_assignments.request_id
      WHERE help_assignments.status = 'completed'
        AND help_assignments.helper_id = md5('user-2')::uuid
      """,
      "leaderboard_aggregate" => """
      SELECT
        help_assignments.helper_id,
        count(help_assignments.id),
        count(DISTINCT help_requests.requester_id),
        count(DISTINCT help_requests.requester_id) FILTER (
          WHERE help_assignments.proximity_observed_at IS NOT NULL
            AND help_assignments.helper_movement_observed_at IS NOT NULL
        ),
        count(DISTINCT help_requests.requester_id) FILTER (
          WHERE help_assignments.handover_verified_at IS NOT NULL
        )
      FROM help_assignments
      INNER JOIN help_requests
        ON help_requests.id = help_assignments.request_id
      WHERE help_assignments.status = 'completed'
      GROUP BY help_assignments.helper_id
      """
    }
  end

  defp explain!(query) do
    result =
      Repo.query!("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) #{query}", [], timeout: :infinity)

    case result.rows do
      [[[plan | _]]] when is_map(plan) -> plan
      [[plan]] when is_map(plan) -> plan
      rows -> Mix.raise("unexpected EXPLAIN JSON result: #{inspect(rows, limit: 2)}")
    end
  end

  defp summarize_plan(plan) do
    nodes = flatten_nodes(Map.fetch!(plan, "Plan"))

    %{
      planning_time_ms: plan["Planning Time"],
      execution_time_ms: plan["Execution Time"],
      node_types: nodes |> Enum.map(& &1["Node Type"]) |> Enum.reject(&is_nil/1),
      index_names: nodes |> Enum.map(& &1["Index Name"]) |> Enum.reject(&is_nil/1) |> Enum.uniq(),
      shared_hit_blocks: plan["Plan"]["Shared Hit Blocks"] || 0,
      shared_read_blocks: plan["Plan"]["Shared Read Blocks"] || 0,
      returned_rows: plan["Plan"]["Actual Rows"]
    }
  end

  defp flatten_nodes(node) do
    [node | Enum.flat_map(Map.get(node, "Plans", []), &flatten_nodes/1)]
  end

  defp expected_indexes do
    %{
      "help_discovery" => "help_requests_discovery_cursor_index",
      "help_requester" => "help_requests_requester_cursor_index",
      "activity_discovery" => "activities_discovery_cursor_index",
      "activity_participant" => "activity_participants_user_id_status_index",
      "messages" => "messages_assignment_cursor_index",
      "activity_messages" => "activity_messages_activity_cursor_index",
      "visible_reviews" => "reviews_visible_cursor_index",
      "reports_open" => "reports_cursor_index",
      "reports_all" => "reports_cursor_index",
      "blocks" => "blocks_blocker_cursor_index",
      "abuse_signals" => "abuse_signals_status_cursor_index",
      "proposals_open" => "category_proposals_cursor_index",
      "proposals_all" => "category_proposals_cursor_index",
      "users" => "users_moderation_cursor_index"
    }
  end

  defp assert_expected_indexes_exist! do
    existing =
      Repo.query!("SELECT indexname FROM pg_indexes WHERE schemaname = current_schema()").rows
      |> List.flatten()
      |> MapSet.new()

    missing =
      expected_indexes()
      |> Map.values()
      |> Enum.uniq()
      |> Enum.reject(&MapSet.member?(existing, &1))

    if missing != [], do: Mix.raise("missing cursor indexes: #{Enum.join(missing, ", ")}")
  end

  defp assert_expected_indexes_used!(plans) do
    failures =
      Enum.reject(expected_indexes(), fn {name, index} ->
        index in get_in(plans, [name, :index_names])
      end)

    if failures != [] do
      details =
        Enum.map_join(failures, ", ", fn {name, index} ->
          "#{name} expected #{index}, observed #{inspect(get_in(plans, [name, :index_names]))}"
        end)

      Mix.raise("cursor plan assertions failed: #{details}")
    end
  end

  defp assert_keyset_pages! do
    {first_ids, cursor} = fetch_help_page(nil)
    {second_ids, _cursor} = fetch_help_page(cursor)

    if length(first_ids) != 25 or length(second_ids) != 25 do
      Mix.raise("keyset page assertion expected two full 25-row pages")
    end

    if MapSet.disjoint?(MapSet.new(first_ids), MapSet.new(second_ids)) == false do
      Mix.raise("keyset page assertion found duplicate rows across pages")
    end

    expected =
      Repo.query!("""
      SELECT id FROM help_requests
      WHERE requester_id = md5('user-1')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 50
      """).rows
      |> List.flatten()

    if first_ids ++ second_ids != expected do
      Mix.raise("keyset page assertion found a gap or order mismatch")
    end
  end

  defp fetch_help_page(nil) do
    result =
      Repo.query!("""
      SELECT id, inserted_at FROM help_requests
      WHERE requester_id = md5('user-1')::uuid
      ORDER BY inserted_at DESC, id DESC LIMIT 25
      """)

    page_result(result)
  end

  defp fetch_help_page({inserted_at, id}) do
    result =
      Repo.query!(
        """
        SELECT id, inserted_at FROM help_requests
        WHERE requester_id = md5('user-1')::uuid
          AND (inserted_at, id) < ($1, $2)
        ORDER BY inserted_at DESC, id DESC LIMIT 25
        """,
        [inserted_at, id]
      )

    page_result(result)
  end

  defp page_result(result) do
    ids = Enum.map(result.rows, &hd/1)
    [id, inserted_at] = List.last(result.rows)
    {ids, {inserted_at, id}}
  end

  defp assert_seed_size!(rows) do
    actual = scalar!("SELECT count(*) FROM help_requests")
    if actual != rows, do: Mix.raise("expected #{rows} seeded help requests, found #{actual}")
  end

  defp table_counts do
    ~w(
      users help_requests help_assignments messages reviews reports blocks
      abuse_signals category_proposals activities activity_participants activity_messages
    )
    |> Map.new(fn table -> {table, scalar!("SELECT count(*) FROM #{table}")} end)
  end

  defp index_sizes do
    Repo.query!("""
    SELECT indexrelname, pg_relation_size(indexrelid)
    FROM pg_stat_user_indexes
    WHERE indexrelname LIKE '%cursor_index'
    ORDER BY indexrelname
    """).rows
    |> Map.new(fn [name, bytes] -> {name, bytes} end)
  end

  defp write_comparison!(output, after_summary) do
    before_path = Path.join(output, "before-summary.json")

    before =
      before_path
      |> File.read!()
      |> Jason.decode!()

    comparisons =
      Map.new(after_summary.plans, fn {name, after_plan} ->
        before_plan = get_in(before, ["plans", name])

        {name,
         %{
           before_execution_time_ms: before_plan["execution_time_ms"],
           after_execution_time_ms: after_plan.execution_time_ms,
           observed_execution_ratio:
             ratio(before_plan["execution_time_ms"], after_plan.execution_time_ms),
           before_index_names: before_plan["index_names"],
           after_index_names: after_plan.index_names
         }}
      end)

    comparison = %{
      note:
        "Observed timings describe this isolated run and are not a portable minimum or latency gate.",
      configured_rows_per_large_table: after_summary.configured_rows_per_large_table,
      plans: comparisons
    }

    File.write!(
      Path.join(output, "comparison.json"),
      Jason.encode_to_iodata!(comparison, pretty: true)
    )

    markdown =
      [
        "# Isolated database scale measurement\n\n",
        "Rows configured per large table: ",
        Integer.to_string(after_summary.configured_rows_per_large_table),
        "\n\n",
        "Timings below are observations from this run, not a portable minimum or an SLO.\n\n",
        "| Query | Before, ms | After, ms | Before indexes | After indexes |\n",
        "| --- | ---: | ---: | --- | --- |\n",
        Enum.map(comparisons, fn {name, values} ->
          [
            "| ",
            name,
            " | ",
            format_ms(values.before_execution_time_ms),
            " | ",
            format_ms(values.after_execution_time_ms),
            " | ",
            Enum.join(values.before_index_names, ", "),
            " | ",
            Enum.join(values.after_index_names, ", "),
            " |\n"
          ]
        end)
      ]

    File.write!(Path.join(output, "README.md"), markdown)
  end

  defp ratio(before_ms, after_ms)
       when is_number(before_ms) and is_number(after_ms) and after_ms > 0,
       do: Float.round(before_ms / after_ms, 3)

  defp ratio(_before_ms, _after_ms), do: nil

  defp format_ms(value) when is_float(value), do: :erlang.float_to_binary(value, decimals: 3)
  defp format_ms(value), do: to_string(value)

  defp scalar!(sql) do
    case Repo.query!(sql).rows do
      [[value]] -> value
      rows -> Mix.raise("expected one scalar value, got: #{inspect(rows)}")
    end
  end

  defp query!(sql), do: Repo.query!(sql, [], timeout: :infinity)
end
