defmodule WhoNeedHelp.MutualAidFlowTest do
  use WhoNeedHelp.DataCase, async: false

  import WhoNeedHelp.AccountsFixtures

  alias WhoNeedHelp.{Accounts, Catalog, CatalogModeration, Help, Messaging, Tracking, Trust}
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Tracking.Position
  alias WhoNeedHelpWeb.Presence

  setup do
    category = Catalog.seed_defaults()
    requester = user_fixture(display_name: "Requester")
    helper = user_fixture(display_name: "Helper")

    request_attrs = %{
      "title" => "Medicine is ready at the pharmacy",
      "description" => "The legal medicine is already reserved and paid for.",
      "pickup_instructions" => "Ask for order WNH, no payment is required.",
      "location_label" => "Central district",
      "latitude" => "50.4501",
      "longitude" => "30.5234",
      "urgency" => "now",
      "location_visibility" => "approximate_public",
      "structured_data" => %{"pickup_status" => "reserved"},
      "expires_at" => DateTime.utc_now(:second) |> DateTime.add(3, :hour),
      "category_id" => category.id,
      "safety_confirmed" => true
    }

    {:ok,
     category: category,
     requester: requester,
     helper: helper,
     requester_scope: user_scope_fixture(requester),
     helper_scope: user_scope_fixture(helper),
     request_attrs: request_attrs}
  end

  test "requester and helper complete a verified handover", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:error, :own_request} = Help.accept_request(context.requester_scope, request.id)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, assignment} = Help.start_assignment(context.helper_scope, assignment.id)

    assert assignment.status == :in_progress

    {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
    {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)

    {:ok, assignment} =
      Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))

    assert assignment.status == :completed
    assert Help.get_request!(request.id).status == :completed

    reputation = Trust.reputation(context.helper.id)
    assert reputation.completed == 1
    assert reputation.unique_people == 1
    assert reputation.verified_handovers == 1

    reputations = Trust.reputations([context.helper.id, context.requester.id])
    assert reputations[context.helper.id] == reputation
    assert reputations[context.requester.id].completed == 1

    unused_user = user_fixture(display_name: "No completed help")

    assert Trust.reputations([unused_user.id])[unused_user.id] == %{
             completed: 0,
             unique_people: 0,
             verified_handovers: 0,
             location_supported: 0,
             rating: nil
           }
  end

  test "invalid proposal identifiers are rejected", context do
    assert {:error, :not_found} = Catalog.vote(context.requester_scope, "not-a-uuid")
    assert {:error, :not_found} = Catalog.unvote(context.requester_scope, "not-a-uuid")
  end

  test "invalid request workflow identifiers and handover codes are rejected", context do
    missing_id = Ecto.UUID.generate()

    for id <- ["not-a-uuid", missing_id] do
      assert {:error, :not_found} = Help.accept_request(context.helper_scope, id)
      assert {:error, :not_found} = Help.cancel_request(context.requester_scope, id)
      assert {:error, :not_found} = Help.start_assignment(context.helper_scope, id)
      assert {:error, :not_found} = Help.confirm_completion(context.helper_scope, id)
      assert {:error, :not_found} = Help.withdraw_assignment(context.helper_scope, id)

      assert {:error, :not_found} =
               Help.verify_handover(context.helper_scope, id, "123456")
    end

    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

    for code <- [%{"nested" => "123456"}, "12345", "1234567", "abcdef"] do
      assert {:error, :invalid_code} =
               Help.verify_handover(context.helper_scope, assignment.id, code)
    end
  end

  test "request location labels are validated before the database write", context do
    assert {:error, changeset} =
             Help.create_request(
               context.requester_scope,
               Map.put(context.request_attrs, "location_label", String.duplicate("x", 256))
             )

    assert "should be at most 255 character(s)" in errors_on(changeset).location_label
  end

  test "forged request coordinate types become validation errors instead of raising", context do
    assert {:error, changeset} =
             Help.create_request(
               context.requester_scope,
               Map.put(context.request_attrs, "longitude", %{"nested" => "value"})
             )

    assert "select a valid location" in errors_on(changeset).location
  end

  test "forged filters return no requests instead of raising", context do
    assert [] ==
             Help.list_open_requests(context.helper_scope, %{
               "category_id" => "not-a-uuid"
             })

    assert [] ==
             Help.list_open_requests(context.helper_scope, %{
               "urgency" => "impossible"
             })
  end

  test "chat is durable and only visible to match participants", context do
    outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

    {:ok, message} =
      Messaging.send_message(context.helper_scope, assignment, %{
        "body" => "I am on my way.",
        "assignment_id" => Ecto.UUID.generate(),
        "sender_id" => context.requester.id
      })

    assert message.body == "I am on my way."
    assert message.assignment_id == assignment.id
    assert message.sender_id == context.helper.id

    assert [%{body: "I am on my way."}] =
             Messaging.list_messages(context.requester_scope, assignment)

    assert [] == Messaging.list_messages(outsider_scope, assignment)

    assert {:error, :forbidden} =
             Messaging.send_message(outsider_scope, assignment, %{"body" => "not allowed"})
  end

  test "non-participants cannot reopen matched request details by UUID", context do
    outsider_scope = user_fixture(display_name: "Outsider") |> user_scope_fixture()
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)

    assert {:ok, _public_request} = Help.get_request(outsider_scope, request.id)
    assert {:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)
    assert {:error, :not_found} = Help.get_request(outsider_scope, request.id)
  end

  test "request details do not preload the unbounded chat history", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

    {:ok, _message} =
      Messaging.send_message(context.helper_scope, assignment, %{"body" => "Bounded separately"})

    loaded_request = Help.get_request!(request.id)

    assert %Ecto.Association.NotLoaded{} = loaded_request.assignment.messages

    assert [%{body: "Bounded separately"}] =
             Messaging.list_messages(context.requester_scope, loaded_request.assignment)
  end

  test "request discovery uses stable cursor pages without gaps or duplicates", context do
    requests =
      for offset <- 1..5 do
        attrs =
          context.request_attrs
          |> Map.put("title", "Cursor request #{offset}")
          |> Map.put(
            "expires_at",
            DateTime.utc_now(:second) |> DateTime.add(offset, :hour)
          )

        {:ok, request} = Help.create_request(context.requester_scope, attrs)
        request
      end

    first = Help.paginate_open_requests(context.helper_scope, %{}, limit: 2)

    second =
      Help.paginate_open_requests(context.helper_scope, %{},
        limit: 2,
        after: first.next_cursor
      )

    third =
      Help.paginate_open_requests(context.helper_scope, %{},
        limit: 2,
        after: second.next_cursor
      )

    expected_ids =
      requests
      |> Enum.sort_by(&{&1.expires_at, &1.id})
      |> Enum.map(& &1.id)

    actual_ids = Enum.map(first.entries ++ second.entries ++ third.entries, & &1.id)

    assert actual_ids == expected_ids
    assert length(Enum.uniq(actual_ids)) == 5
    assert is_binary(first.next_cursor)
    assert is_binary(second.next_cursor)
    assert third.next_cursor == nil
  end

  test "matched chat loads newest cursor pages and preserves chronological order", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)

    messages =
      for offset <- 1..5 do
        {:ok, message} =
          Messaging.send_message(context.helper_scope, assignment, %{
            "body" => "Cursor message #{offset}"
          })

        message
      end

    newest = Messaging.paginate_messages(context.requester_scope, assignment, limit: 2)

    middle =
      Messaging.paginate_messages(context.requester_scope, assignment,
        limit: 2,
        after: newest.next_cursor
      )

    oldest =
      Messaging.paginate_messages(context.requester_scope, assignment,
        limit: 2,
        after: middle.next_cursor
      )

    expected_ids =
      messages
      |> Enum.sort_by(&{&1.inserted_at, &1.id})
      |> Enum.map(& &1.id)

    actual_ids = Enum.map(oldest.entries ++ middle.entries ++ newest.entries, & &1.id)

    assert actual_ids == expected_ids
    assert length(Enum.uniq(actual_ids)) == 5
    assert oldest.next_cursor == nil
  end

  test "chat and review return not found for a stale assignment reference", context do
    stale_assignment = %WhoNeedHelp.Help.Assignment{id: Ecto.UUID.generate()}

    assert {:error, :not_found} =
             Messaging.send_message(context.helper_scope, stale_assignment, %{
               "body" => "This assignment no longer exists"
             })

    assert {:error, :not_found} =
             Trust.submit_review(context.helper_scope, stale_assignment, %{
               "rating" => 5,
               "comment" => "This assignment no longer exists"
             })
  end

  test "stopping tracking deletes the exact current position", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, _session} = Tracking.start_session(context.helper_scope, assignment)

    {:ok, position} =
      Tracking.update_position(context.helper_scope, assignment, %{
        "latitude" => 50.45,
        "longitude" => 30.52,
        "accuracy_meters" => 12.0,
        "tracking_session_id" => Ecto.UUID.generate()
      })

    assert Repo.get(Position, position.id)
    assert {:ok, :stopped} = Tracking.stop_session(context.helper_scope, assignment)
    refute Repo.get(Position, position.id)
  end

  test "forged tracking coordinate types become changeset errors instead of raising", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, _session} = Tracking.start_session(context.helper_scope, assignment)

    assert {:error, changeset} =
             Tracking.update_position(context.helper_scope, assignment, %{
               "latitude" => %{"nested" => "value"},
               "longitude" => 30.52,
               "accuracy_meters" => 12.0
             })

    assert "is invalid" in errors_on(changeset).position
  end

  test "concurrent tracking stops serialize and broadcast once", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, _session} = Tracking.start_session(context.helper_scope, assignment)
    :ok = Tracking.subscribe(assignment.id)

    results =
      1..2
      |> Task.async_stream(
        fn _ -> Tracking.stop_session(context.helper_scope, assignment) end,
        max_concurrency: 2,
        ordered: false
      )
      |> Enum.map(fn {:ok, result} -> result end)
      |> Enum.sort()

    assert results == [{:ok, :already_stopped}, {:ok, :stopped}]
    assert_receive {:tracking_stopped, helper_id}, 1_000
    assert helper_id == context.helper.id
    refute_receive {:tracking_stopped, ^helper_id}, 100
  end

  test "stale browser cleanup cannot stop a replacement tracking session", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, old_session} = Tracking.start_session(context.helper_scope, assignment)
    assert {:ok, :stopped} = Tracking.stop_session(context.helper_scope, assignment)

    {:ok, current_session} = Tracking.start_session(context.helper_scope, assignment)
    refute current_session.id == old_session.id

    assert {:ok, :already_stopped} =
             Tracking.stop_browser_session(
               assignment.id,
               context.helper.id,
               old_session.id
             )

    assert Tracking.active_session?(context.helper_scope, assignment)
  end

  test "reviews remain hidden until both participants submit", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
    {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)

    {:ok, assignment} =
      Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))

    {:ok, _} =
      Trust.submit_review(context.requester_scope, assignment, %{
        "rating" => 5,
        "comment" => "Kind"
      })

    assert Trust.review_submitted?(context.requester_scope, assignment)
    refute Trust.review_submitted?(context.helper_scope, assignment)
    assert Trust.visible_reviews(context.helper.id) == []

    {:ok, _} =
      Trust.submit_review(context.helper_scope, assignment, %{"rating" => 5, "comment" => "Clear"})

    assert Trust.review_submitted?(context.helper_scope, assignment)
    assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
    assert [%{rating: 5}] = Trust.visible_reviews(context.requester.id)
  end

  test "concurrent blind reviews are both revealed", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, _} = Help.confirm_completion(context.requester_scope, assignment.id)
    {:ok, _} = Help.confirm_completion(context.helper_scope, assignment.id)

    {:ok, assignment} =
      Help.verify_handover(context.helper_scope, assignment.id, Help.handover_code(request.id))

    submissions = [
      {context.requester_scope, %{"rating" => 5, "comment" => "Kind"}},
      {context.helper_scope, %{"rating" => 4, "comment" => "Clear"}}
    ]

    results =
      submissions
      |> Task.async_stream(
        fn {scope, attrs} -> Trust.submit_review(scope, assignment, attrs) end,
        max_concurrency: 2,
        ordered: false
      )
      |> Enum.map(fn {:ok, result} -> result end)

    assert Enum.all?(results, &match?({:ok, _review}, &1))
    assert [%{rating: 5}] = Trust.visible_reviews(context.helper.id)
    assert [%{rating: 4}] = Trust.visible_reviews(context.requester.id)
  end

  test "category moderation export excludes proposer identity", context do
    {:ok, proposal} =
      Catalog.propose(context.requester_scope, %{
        "proposed_name" => "Bicycle repair",
        "reason" => "A reusable category for urgent roadside bicycle help."
      })

    export = CatalogModeration.export_open_proposals()
    decoded = Jason.decode!(export)
    [proposal_export] = decoded["proposals"]

    assert proposal_export["proposal_id"] == proposal.id
    assert proposal_export["proposed_name"] == "Bicycle repair"
    assert proposal_export["mode"] == "help"
    assert proposal_export["community_votes"] == 0

    [listed] = Catalog.list_proposals()
    assert listed.id == proposal.id
    assert listed.vote_count == 0
    assert %Ecto.Association.NotLoaded{} = listed.votes

    assert {:ok, _vote} = Catalog.vote(context.helper_scope, proposal.id)
    assert [%{id: listed_id, vote_count: 1}] = Catalog.list_proposals()
    assert listed_id == proposal.id

    refute export =~ context.requester.email
    refute export =~ context.requester.display_name
  end

  test "default urgent and roadside categories are hierarchical and idempotent", context do
    first_ids =
      Catalog.list_categories()
      |> Map.new(&{&1.slug, &1.id})

    assert Map.keys(first_ids) |> Enum.sort() ==
             Enum.sort([
               "bicycle-roadside-help",
               "car-wheel-help",
               "fuel-delivery",
               "medicine-pickup",
               "motorcycle-roadside-help",
               "secured-incident-support",
               "vehicle-breakdown-help"
             ])

    motorcycle = Enum.find(Catalog.list_categories(), &(&1.slug == "motorcycle-roadside-help"))
    assert Catalog.category_path(motorcycle, :en) == "Roadside help — Motorcycle roadside help"
    assert Catalog.category_path(motorcycle, :ru) == "Помощь в дороге — Помощь с мотоциклом"

    assert {:ok,
            %{
              "motorcycle_issue" => "broken_chain",
              "vehicle_in_safe_place" => true
            }} =
             Catalog.validate_structured_data(motorcycle, %{
               "motorcycle_issue" => "broken_chain",
               "vehicle_in_safe_place" => "true"
             })

    assert {:error, errors} =
             Catalog.validate_structured_data(motorcycle, %{
               "motorcycle_issue" => "invented",
               "vehicle_in_safe_place" => "true",
               "unexpected" => "value"
             })

    assert "motorcycle_issue has an invalid value" in errors
    assert "unexpected is not allowed" in errors

    assert context.category.id == Catalog.seed_defaults().id
    assert first_ids == Catalog.list_categories() |> Map.new(&{&1.slug, &1.id})
    assert Repo.aggregate(WhoNeedHelp.Catalog.Category, :count) == 14
  end

  test "roadside structured data is enforced when creating a request", context do
    motorcycle =
      Catalog.list_categories()
      |> Enum.find(&(&1.slug == "motorcycle-roadside-help"))

    attrs =
      context.request_attrs
      |> Map.put("category_id", motorcycle.id)
      |> Map.put("structured_data", %{
        "motorcycle_issue" => "broken_chain",
        "vehicle_in_safe_place" => "true"
      })

    assert {:ok, request} = Help.create_request(context.requester_scope, attrs)
    assert request.structured_data["motorcycle_issue"] == "broken_chain"
    assert request.structured_data["vehicle_in_safe_place"] == true

    invalid =
      attrs
      |> Map.put("title", "Another roadside request")
      |> Map.put("structured_data", %{"motorcycle_issue" => "broken_chain"})

    assert {:error, changeset} = Help.create_request(context.requester_scope, invalid)
    assert "vehicle_in_safe_place is required" in errors_on(changeset).structured_data
  end

  test "manual social links stay unverified and owner-controlled", context do
    {:ok, identity} =
      Accounts.add_social_identity(context.requester, %{
        "provider" => "telegram",
        "profile_url" => "https://t.me/helpful_neighbor",
        "handle" => "@helpful_neighbor",
        "verified_at" => DateTime.utc_now(:second),
        "provider_uid" => "must-not-be-user-controlled"
      })

    assert is_nil(identity.verified_at)
    assert is_nil(identity.provider_uid)
    assert [listed] = Accounts.list_social_identities(context.requester)
    assert listed.id == identity.id
    assert {:error, :not_found} = Accounts.delete_social_identity(context.helper, identity.id)
    assert {:ok, _identity} = Accounts.delete_social_identity(context.requester, identity.id)
  end

  test "request details batch public users and public social identities", context do
    {:ok, requester_identity} =
      Accounts.upsert_verified_social_identity(context.requester, %{
        provider: :github,
        provider_uid: "requester-private-provider-id",
        profile_url: "https://github.com/requester",
        handle: "requester",
        verified_at: DateTime.utc_now(:second)
      })

    {:ok, helper_identity} =
      Accounts.upsert_verified_social_identity(context.helper, %{
        provider: :github,
        provider_uid: "helper-private-provider-id",
        profile_url: "https://github.com/helper",
        handle: "helper",
        verified_at: DateTime.utc_now(:second)
      })

    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, _assignment} = Help.accept_request(context.helper_scope, request.id)

    handler_id = "request-detail-queries-#{System.unique_integer([:positive])}"

    :ok =
      :telemetry.attach(
        handler_id,
        [:who_need_help, :repo, :query],
        fn _event, _measurements, metadata, receiver ->
          send(receiver, {:request_detail_query, metadata.query})
        end,
        self()
      )

    loaded =
      try do
        {:ok, loaded} = Help.get_request(context.requester_scope, request.id)
        loaded
      after
        :telemetry.detach(handler_id)
      end

    queries = collect_request_detail_queries()

    assert Enum.count(queries, &String.contains?(&1, ~s(FROM "users"))) == 1
    assert Enum.count(queries, &String.contains?(&1, ~s(FROM "social_identities"))) == 1

    assert [loaded_requester_identity] = loaded.requester.social_identities
    assert loaded_requester_identity.id == requester_identity.id
    assert is_nil(loaded_requester_identity.provider_uid)

    assert [loaded_helper_identity] = loaded.assignment.helper.social_identities
    assert loaded_helper_identity.id == helper_identity.id
    assert is_nil(loaded_helper_identity.provider_uid)
  end

  test "browser tracking stops only after the last tab leaves", context do
    {:ok, request} = Help.create_request(context.requester_scope, context.request_attrs)
    {:ok, assignment} = Help.accept_request(context.helper_scope, request.id)
    {:ok, session} = Tracking.start_session(context.helper_scope, assignment)
    :ok = Tracking.subscribe(assignment.id)

    parent = self()

    start_browser = fn ->
      spawn(fn ->
        {:ok, key} =
          Presence.track_browser(self(), assignment.id, context.helper.id, session.id)

        send(parent, {:browser_ready, self(), key})

        receive do
          :close -> :ok
        end
      end)
    end

    first = start_browser.()
    second = start_browser.()
    key = Presence.tracking_key(assignment.id, context.helper.id)

    assert_receive {:browser_ready, ^first, ^key}
    assert_receive {:browser_ready, ^second, ^key}
    assert wait_for_presence_count(key, 2)

    first_ref = Process.monitor(first)
    send(first, :close)
    assert_receive {:DOWN, ^first_ref, :process, ^first, :normal}
    assert wait_for_presence_count(key, 1)
    assert Tracking.active_session?(context.helper_scope, assignment)

    second_ref = Process.monitor(second)
    send(second, :close)
    assert_receive {:DOWN, ^second_ref, :process, ^second, :normal}
    assert_receive {:tracking_stopped, helper_id}, 1_000
    assert helper_id == context.helper.id
    refute Tracking.active_session?(context.helper_scope, assignment)
  end

  defp wait_for_presence_count(key, expected, attempts \\ 100)

  defp wait_for_presence_count(_key, _expected, 0), do: false

  defp wait_for_presence_count(key, expected, attempts) do
    count =
      case Presence.get_by_key(Presence.tracking_topic(), key) do
        [] -> 0
        %{metas: metas} -> length(metas)
      end

    if count == expected do
      true
    else
      Process.sleep(10)
      wait_for_presence_count(key, expected, attempts - 1)
    end
  end

  defp collect_request_detail_queries(queries \\ []) do
    receive do
      {:request_detail_query, query} ->
        collect_request_detail_queries([query | queries])
    after
      0 -> Enum.reverse(queries)
    end
  end
end
