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

  import WhoNeedHelp.AccountsFixtures

  alias WhoNeedHelp.{Activities, Catalog, Trust}
  alias WhoNeedHelp.Activities.Activity
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Trust.AuditEvent

  setup do
    Catalog.seed_defaults()

    organizer = user_fixture(display_name: "Organizer")
    participant = user_fixture(display_name: "Participant")
    outsider = user_fixture(display_name: "Outsider")

    coffee =
      Catalog.list_categories(:activity)
      |> Enum.find(&(&1.slug == "coffee-meetup"))

    attrs = %{
      "title" => "Coffee and conversation",
      "description" => "Meet at a public café for conversation and tea.",
      "structured_data" => %{"setting" => "cafe"},
      "location_label" => "Central square",
      "latitude" => "50.4501",
      "longitude" => "30.5234",
      "location_visibility" => "approximate_public",
      "starts_at" => DateTime.utc_now(:second) |> DateTime.add(2, :hour),
      "join_deadline" => DateTime.utc_now(:second) |> DateTime.add(1, :hour),
      "capacity" => 2,
      "category_id" => coffee.id,
      "safety_confirmed" => true
    }

    {:ok,
     organizer: organizer,
     participant: participant,
     outsider: outsider,
     organizer_scope: user_scope_fixture(organizer),
     participant_scope: user_scope_fixture(participant),
     outsider_scope: user_scope_fixture(outsider),
     coffee: coffee,
     attrs: attrs}
  end

  test "organizer approves a participant and the approved group can chat", context do
    assert {:ok, activity} =
             Activities.create_activity(context.organizer_scope, context.attrs)

    assert [%{role: :organizer, status: :approved}] = activity.participants

    assert Activity.public_coordinates(activity) == %{
             latitude: 50.45,
             longitude: 30.52,
             exact: false
           }

    assert Activities.coordinates_for(context.outsider_scope, activity).exact == false
    assert Activities.coordinates_for(context.organizer_scope, activity).exact == true

    assert {:error, :forbidden} =
             Activities.send_message(context.outsider_scope, activity.id, %{"body" => "Hello"})

    assert {:ok, request} =
             Activities.request_to_join(context.participant_scope, activity.id)

    assert request.status == :requested

    assert {:ok, approved} =
             Activities.approve_participant(context.organizer_scope, request.id)

    assert approved.status == :approved

    assert {:ok, message} =
             Activities.send_message(context.participant_scope, activity.id, %{
               "body" => "See you at the café."
             })

    assert message.body == "See you at the café."

    assert {:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)
    assert [%{body: "See you at the café."}] = loaded.messages
    assert Activities.coordinates_for(context.participant_scope, loaded).exact == true

    assert {:error, :capacity_reached} =
             Activities.request_to_join(context.outsider_scope, activity.id)
  end

  test "global activity events carry only the bounded index projection", context do
    :ok = Activities.subscribe()
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)

    assert_receive {:activity_created, created}
    assert created.id == activity.id
    assert created.approved_participant_count == 1
    assert %Ecto.Association.NotLoaded{} = created.participants
    assert %Ecto.Association.NotLoaded{} = created.messages
    assert %Ecto.Association.NotLoaded{} = created.creator.social_identities

    :ok = Activities.subscribe_user(context.participant_scope)
    {:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)

    assert_receive {:activity_updated, updated}
    assert updated.id == activity.id
    assert updated.approved_participant_count == 1
    assert %Ecto.Association.NotLoaded{} = updated.participants
    assert %Ecto.Association.NotLoaded{} = updated.messages
    assert_receive {:my_activity_updated, participant_update, true}
    assert participant_update.id == activity.id
  end

  test "invalid and missing client identifiers are rejected without raising", context do
    assert {:error, :not_found} =
             Activities.approve_participant(context.organizer_scope, "not-a-uuid")

    assert {:error, :not_found} =
             Activities.decline_participant(context.organizer_scope, "not-a-uuid")

    missing_id = Ecto.UUID.generate()

    assert {:error, :not_found} =
             Activities.approve_participant(context.organizer_scope, missing_id)

    assert {:error, :not_found} =
             Activities.decline_participant(context.organizer_scope, missing_id)

    assert {:error, :not_found} =
             Activities.request_to_join(context.participant_scope, missing_id)

    assert {:error, :not_found} =
             Activities.send_message(context.participant_scope, missing_id, %{"body" => "No"})

    assert {:error, :not_found} =
             Activities.cancel_activity(context.organizer_scope, missing_id)

    assert {:error, :not_found} =
             Activities.complete_activity(context.organizer_scope, missing_id)

    for invalid_id <- ["not-a-uuid", missing_id] do
      assert {:error, :not_found} =
               Activities.leave_activity(context.participant_scope, invalid_id)

      assert {:error, :not_found} =
               Activities.request_to_join(context.participant_scope, invalid_id)

      assert {:error, :not_found} =
               Activities.send_message(context.participant_scope, invalid_id, %{"body" => "No"})

      assert {:error, :not_found} =
               Activities.cancel_activity(context.organizer_scope, invalid_id)

      assert {:error, :not_found} =
               Activities.complete_activity(context.organizer_scope, invalid_id)
    end
  end

  test "activity location labels are validated before the database write", context do
    assert {:error, changeset} =
             Activities.create_activity(
               context.organizer_scope,
               Map.put(context.attrs, "location_label", String.duplicate("x", 256))
             )

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

  test "activity capacity outside the PostgreSQL integer range is rejected before insert",
       context do
    assert {:error, changeset} =
             Activities.create_activity(
               context.organizer_scope,
               Map.put(context.attrs, "capacity", 2_147_483_648)
             )

    assert "must be less than or equal to 2147483647" in errors_on(changeset).capacity
  end

  test "forged coordinate types become validation errors instead of raising", context do
    assert {:error, changeset} =
             Activities.create_activity(
               context.organizer_scope,
               Map.put(context.attrs, "latitude", %{"nested" => "value"})
             )

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

  test "forged category filters return no activities instead of raising", context do
    assert {:ok, _activity} =
             Activities.create_activity(context.organizer_scope, context.attrs)

    assert [] ==
             Activities.list_open_activities(context.participant_scope, %{
               "category_id" => "not-a-uuid"
             })
  end

  test "unapproved viewers receive no private chat or pending participant data", context do
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
    {:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)

    assert {:ok, public_activity} =
             Activities.get_activity(context.outsider_scope, activity.id)

    assert public_activity.messages == []
    assert Enum.all?(public_activity.participants, &(&1.status == :approved))
    refute Enum.any?(public_activity.participants, &(&1.id == request.id))

    assert {:ok, requester_view} =
             Activities.get_activity(context.participant_scope, activity.id)

    assert requester_view.messages == []
    assert Enum.any?(requester_view.participants, &(&1.id == request.id))
  end

  test "activity completion never changes urgent-help reputation", context do
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
    {:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
    {:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
    assert {:ok, completed} = Activities.complete_activity(context.organizer_scope, activity.id)
    assert completed.status == :completed

    assert Trust.reputation(context.organizer.id).completed == 0
    assert Trust.reputation(context.participant.id).completed == 0
    assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)
    assert {:ok, _activity} = Activities.get_activity(context.participant_scope, activity.id)
  end

  test "activity category and structured fields are enforced", context do
    hiking =
      Catalog.list_categories(:activity)
      |> Enum.find(&(&1.slug == "hiking-group"))

    invalid =
      context.attrs
      |> Map.put("category_id", hiking.id)
      |> Map.put("structured_data", %{
        "difficulty" => "easy",
        "route_plan" => "Marked public trail"
      })

    assert {:error, changeset} =
             Activities.create_activity(context.organizer_scope, invalid)

    assert "safety_plan_confirmed is required" in errors_on(changeset).structured_data

    help_category = Catalog.seed_defaults()

    invalid =
      context.attrs
      |> Map.put("category_id", help_category.id)
      |> Map.put("structured_data", %{"pickup_status" => "reserved"})

    assert {:error, changeset} =
             Activities.create_activity(context.organizer_scope, invalid)

    assert "select an activity category" in errors_on(changeset).category_id
  end

  test "blocking prevents activity discovery and joining", context do
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
    assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)
    assert [] = Activities.list_open_activities(context.participant_scope)

    assert {:error, :blocked} =
             Activities.request_to_join(context.participant_scope, activity.id)
  end

  test "blocking an approved participant revokes exact location and group chat", context do
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
    {:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
    {:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)
    {:ok, loaded} = Activities.get_activity(context.participant_scope, activity.id)

    assert Activities.coordinates_for(context.participant_scope, loaded).exact
    assert {:ok, _block} = Trust.block(context.organizer_scope, context.participant.id)

    assert {:error, :not_found} =
             Activities.get_activity(context.participant_scope, activity.id)

    assert Activities.coordinates_for(context.participant_scope, loaded).exact == false
    assert Activities.paginate_messages(context.participant_scope, loaded).entries == []

    assert {:error, :blocked} =
             Activities.send_message(context.participant_scope, activity.id, %{
               "body" => "This must not be delivered"
             })
  end

  test "activity creation requires server-side safety consent", context do
    attrs = Map.delete(context.attrs, "safety_confirmed")

    assert {:error, changeset} =
             Activities.create_activity(context.organizer_scope, attrs)

    assert "confirm the safety guidance before publishing" in errors_on(changeset).safety_confirmed
  end

  test "activity discovery uses stable cursor pages", context do
    activities =
      for offset <- 2..5 do
        attrs =
          context.attrs
          |> Map.put("title", "Cursor activity #{offset}")
          |> Map.put(
            "starts_at",
            DateTime.utc_now(:second) |> DateTime.add(offset, :hour)
          )
          |> Map.put(
            "join_deadline",
            DateTime.utc_now(:second) |> DateTime.add(offset - 1, :hour)
          )

        {:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
        activity
      end

    first = Activities.paginate_open_activities(context.outsider_scope, %{}, limit: 2)

    second =
      Activities.paginate_open_activities(context.outsider_scope, %{},
        limit: 2,
        after: first.next_cursor
      )

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

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

    assert actual_ids == expected_ids
    assert length(Enum.uniq(actual_ids)) == 4
    assert is_binary(first.next_cursor)
    assert second.next_cursor == nil
    assert Enum.all?(first.entries ++ second.entries, &(&1.approved_participant_count == 1))

    assert Enum.all?(first.entries ++ second.entries, fn activity ->
             match?(%Ecto.Association.NotLoaded{}, activity.participants)
           end)
  end

  test "my activities are derived from the organizer and participant membership rows", context do
    {:ok, organized} = Activities.create_activity(context.organizer_scope, context.attrs)
    {:ok, request} = Activities.request_to_join(context.participant_scope, organized.id)

    assert Enum.map(Activities.list_my_activities(context.organizer_scope), & &1.id) == [
             organized.id
           ]

    assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
             organized.id
           ]

    assert {:ok, _left} = Activities.leave_activity(context.participant_scope, organized.id)
    assert Activities.list_my_activities(context.participant_scope) == []
    assert request.status == :requested
  end

  test "my activities exclude organizers blocked in either direction", context do
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)
    {:ok, _request} = Activities.request_to_join(context.participant_scope, activity.id)

    assert Enum.map(Activities.list_my_activities(context.participant_scope), & &1.id) == [
             activity.id
           ]

    assert {:ok, _block} = Trust.block(context.participant_scope, context.organizer.id)
    assert Activities.list_my_activities(context.participant_scope) == []
  end

  test "activity message reports expose only the linked group to audited moderators", context do
    moderator =
      user_fixture(display_name: "Activity moderator")
      |> Ecto.Changeset.change(role: :moderator)
      |> Repo.update!()

    attrs = Map.put(context.attrs, "capacity", 3)
    {:ok, activity} = Activities.create_activity(context.organizer_scope, attrs)
    {:ok, request} = Activities.request_to_join(context.participant_scope, activity.id)
    {:ok, _approved} = Activities.approve_participant(context.organizer_scope, request.id)

    {:ok, organizer_message} =
      Activities.send_message(context.organizer_scope, activity.id, %{
        "body" => "Meet beside the public entrance."
      })

    {:ok, participant_message} =
      Activities.send_message(context.participant_scope, activity.id, %{
        "body" => "Understood."
      })

    assert {:error, :forbidden} =
             Trust.report(context.outsider_scope, %{
               "reason" => "harassment",
               "details" => "I should not see this private message.",
               "activity_message_id" => organizer_message.id
             })

    assert {:ok, report} =
             Trust.report(context.participant_scope, %{
               "reason" => "harassment",
               "details" => "Please review this approved group conversation.",
               "activity_message_id" => organizer_message.id
             })

    assert {:ok, evidence} =
             Trust.report_evidence(user_scope_fixture(moderator), report.id)

    assert evidence.messages == []

    expected_message_ids =
      [organizer_message, participant_message]
      |> Enum.sort_by(&{&1.inserted_at, &1.id})
      |> Enum.map(& &1.id)

    assert Enum.map(evidence.activity_messages, & &1.id) == expected_message_ids

    assert Repo.exists?(
             from event in AuditEvent,
               where:
                 event.action == "report.evidence_viewed" and event.target_id == ^report.id and
                   event.actor_id == ^moderator.id
           )
  end

  test "moderators can hide and restore a reported activity", context do
    moderator =
      user_fixture(display_name: "Activity moderator")
      |> Ecto.Changeset.change(role: :moderator)
      |> Repo.update!()

    moderator_scope = user_scope_fixture(moderator)
    {:ok, activity} = Activities.create_activity(context.organizer_scope, context.attrs)

    assert {:error, :cannot_report_self} =
             Trust.report(context.organizer_scope, %{
               "reason" => "other",
               "details" => "Cannot report my own activity.",
               "activity_id" => activity.id
             })

    assert {:ok, _report} =
             Trust.report(context.outsider_scope, %{
               "reason" => "dangerous_request",
               "details" => "The public plan needs moderator review.",
               "activity_id" => activity.id
             })

    assert {:ok, hidden} =
             Trust.hide_activity(moderator_scope, activity.id, "Safety review")

    assert hidden.hidden_at
    assert {:error, :not_found} = Activities.get_activity(context.outsider_scope, activity.id)

    assert {:ok, restored} = Trust.restore_activity(moderator_scope, activity.id)
    assert is_nil(restored.hidden_at)
    assert {:ok, _activity} = Activities.get_activity(context.outsider_scope, activity.id)
  end
end
