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

  import Ecto.Query

  alias Oban.Job
  alias WhoNeedHelp.Accounts.{SocialIdentity, User}
  alias WhoNeedHelp.Activities.{Activity, Participant}
  alias WhoNeedHelp.Activities.Message, as: ActivityMessage
  alias WhoNeedHelp.Catalog.{CategoryProposal, CategoryVote}
  alias WhoNeedHelp.Help.{Assignment, HelpRequest}
  alias WhoNeedHelp.Messaging.Message
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Tracking.{Position, TrackingSession}

  alias WhoNeedHelp.Trust.{
    AbuseSignal,
    AuditEvent,
    Block,
    Report,
    Review
  }

  @shortdoc "Prepares or removes the exact full public-staging browser fixture"
  @confirmation "public-staging-full-e2e"
  @precreated_roles ~w(requester helper activity-organizer activity-participant admin)
  @registered_roles ~w(auth-user auth-user-changed)

  @impl Mix.Task
  def run([action]) when action in ["prepare", "cleanup"] do
    Mix.Task.run("app.start")
    context = verified_context()

    case action do
      "prepare" -> prepare(context)
      "cleanup" -> cleanup(context)
    end
  end

  def run(_args) do
    Mix.raise("usage: mix wnh.staging_full_e2e prepare|cleanup")
  end

  defp verified_context do
    run_id = required_env!("WNH_STAGING_E2E_RUN_ID")
    expected_database = required_env!("WNH_STAGING_E2E_EXPECTED_DATABASE")
    manifest_path = required_env!("WNH_STAGING_E2E_MANIFEST_PATH") |> Path.expand()

    unless Regex.match?(~r/^[a-z0-9-]+$/, run_id) do
      Mix.raise("WNH_STAGING_E2E_RUN_ID may contain only lowercase letters, numbers, and dash")
    end

    unless System.get_env("WNH_STAGING_E2E_CONFIRM") == @confirmation do
      Mix.raise("WNH_STAGING_E2E_CONFIRM must equal #{@confirmation}")
    end

    unless String.starts_with?(manifest_path, "/output/") do
      Mix.raise("WNH_STAGING_E2E_MANIFEST_PATH must resolve below /output")
    end

    %Postgrex.Result{rows: [[actual_database]]} =
      Repo.query!("SELECT current_database()", [], log: false)

    unless actual_database == expected_database do
      Mix.raise(
        "refusing staging E2E mutation: expected database #{inspect(expected_database)}, " <>
          "observed #{inspect(actual_database)}"
      )
    end

    %{
      run_id: run_id,
      database: actual_database,
      manifest_path: manifest_path,
      emails: emails(run_id)
    }
  end

  defp prepare(context) do
    password = required_env!("WNH_STAGING_E2E_PASSWORD")

    unless byte_size(password) in 12..72 do
      Mix.raise("WNH_STAGING_E2E_PASSWORD must contain between 12 and 72 bytes")
    end

    assert_prefix_unused!(context)

    password_hash = Bcrypt.hash_pwd_salt(password)
    now = DateTime.utc_now(:second)

    {:ok, users} =
      Repo.transaction(fn ->
        Map.new(@precreated_roles, fn role ->
          user_role = if role == "admin", do: :admin, else: :user

          user =
            insert_user!(
              context.emails[role],
              display_name(role),
              password_hash,
              user_role,
              now
            )

          {role, %{"id" => user.id, "email" => user.email}}
        end)
      end)

    manifest = %{
      "schema_version" => 1,
      "run_id" => context.run_id,
      "database" => context.database,
      "precreated_users" => users,
      "allowed_emails" => context.emails |> Map.values() |> Enum.sort()
    }

    context.manifest_path |> Path.dirname() |> File.mkdir_p!()
    File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
    File.chmod!(context.manifest_path, 0o600)

    Mix.shell().info("prepared exact full staging E2E users for #{context.run_id}")
  end

  defp cleanup(context) do
    manifest = context.manifest_path |> File.read!() |> Jason.decode!()
    validate_manifest!(context, manifest)
    assert_only_allowed_prefix_users!(context)

    users =
      User
      |> where([user], user.email in ^Map.values(context.emails))
      |> Repo.all()

    validate_precreated_users!(manifest, users)

    user_ids = Enum.map(users, & &1.id)
    request_ids = ids(HelpRequest, :requester_id, user_ids)

    assignment_ids =
      Assignment
      |> where(
        [assignment],
        assignment.request_id in ^request_ids or assignment.helper_id in ^user_ids
      )
      |> select([assignment], assignment.id)
      |> Repo.all()

    validate_assignments!(assignment_ids, request_ids, user_ids)

    message_ids = ids(Message, :assignment_id, assignment_ids)
    tracking_session_ids = ids(TrackingSession, :assignment_id, assignment_ids)

    activity_ids = ids(Activity, :creator_id, user_ids)
    validate_activity_membership!(activity_ids, user_ids)
    activity_participant_ids = ids(Participant, :activity_id, activity_ids)
    activity_message_ids = ids(ActivityMessage, :activity_id, activity_ids)

    proposal_ids = ids(CategoryProposal, :proposer_id, user_ids)
    validate_category_votes!(proposal_ids, user_ids)

    report_ids =
      Report
      |> where(
        [report],
        report.reporter_id in ^user_ids or report.request_id in ^request_ids or
          report.assignment_id in ^assignment_ids or report.message_id in ^message_ids or
          report.activity_id in ^activity_ids or
          report.activity_message_id in ^activity_message_ids
      )
      |> select([report], report.id)
      |> Repo.all()

    validate_reviewed_records!(user_ids, report_ids, proposal_ids)

    abuse_signal_ids =
      AbuseSignal
      |> where(
        [signal],
        signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids
      )
      |> select([signal], signal.id)
      |> Repo.all()

    validate_audits(%{
      users: user_ids,
      requests: request_ids,
      assignments: assignment_ids,
      activities: activity_ids,
      activity_participants: activity_participant_ids,
      proposals: proposal_ids,
      reports: report_ids,
      signals: abuse_signal_ids
    })

    job_ids = fixture_push_job_ids(user_ids)

    {:ok, deleted} =
      Repo.transaction(fn ->
        %{
          push_jobs: delete_ids(Job, job_ids),
          audit_events:
            AuditEvent
            |> where([event], event.actor_id in ^user_ids)
            |> delete_count(),
          reports: delete_ids(Report, report_ids),
          activity_messages: delete_ids(ActivityMessage, activity_message_ids),
          activity_participants: delete_ids(Participant, activity_participant_ids),
          activities: delete_ids(Activity, activity_ids),
          tracking_positions:
            Position
            |> where([position], position.tracking_session_id in ^tracking_session_ids)
            |> delete_count(),
          tracking_sessions: delete_ids(TrackingSession, tracking_session_ids),
          messages: delete_ids(Message, message_ids),
          abuse_signals: delete_ids(AbuseSignal, abuse_signal_ids),
          reviews:
            Review
            |> where(
              [review],
              review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or
                review.reviewee_id in ^user_ids
            )
            |> delete_count(),
          assignments: delete_ids(Assignment, assignment_ids),
          requests: delete_ids(HelpRequest, request_ids),
          category_votes:
            CategoryVote
            |> where(
              [vote],
              vote.proposal_id in ^proposal_ids or vote.user_id in ^user_ids
            )
            |> delete_count(),
          category_proposals: delete_ids(CategoryProposal, proposal_ids),
          blocks:
            Block
            |> where([block], block.blocker_id in ^user_ids or block.blocked_id in ^user_ids)
            |> delete_count(),
          social_identities:
            SocialIdentity
            |> where([identity], identity.user_id in ^user_ids)
            |> delete_count(),
          users:
            User
            |> where([user], user.id in ^user_ids)
            |> delete_count()
        }
      end)

    unless deleted.users == length(users) and deleted.users >= length(@precreated_roles) do
      Mix.raise(
        "cleanup removed #{deleted.users} users, expected exactly the #{length(users)} observed fixtures"
      )
    end

    assert_prefix_unused!(context)
    Mix.shell().info("removed exact full staging E2E fixture: #{inspect(deleted)}")
  end

  defp validate_manifest!(context, manifest) do
    expected_emails = context.emails |> Map.values() |> Enum.sort()
    precreated = manifest["precreated_users"]

    valid_precreated? =
      is_map(precreated) and
        Enum.all?(@precreated_roles, fn role ->
          case precreated[role] do
            %{"id" => id, "email" => email} ->
              uuid?(id) and email == context.emails[role]

            _other ->
              false
          end
        end)

    unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
             manifest["database"] == context.database and
             manifest["allowed_emails"] == expected_emails and valid_precreated? do
      Mix.raise("full staging E2E manifest does not match the requested run and database")
    end
  end

  defp validate_precreated_users!(manifest, users) do
    observed = Map.new(users, &{&1.email, &1.id})

    unless Enum.all?(@precreated_roles, fn role ->
             expected = manifest["precreated_users"][role]
             observed[expected["email"]] == expected["id"]
           end) do
      Mix.raise("full staging E2E precreated user ownership does not match the manifest")
    end
  end

  defp validate_assignments!(assignment_ids, request_ids, user_ids) do
    unexpected? =
      Repo.exists?(
        from assignment in Assignment,
          where:
            assignment.id in ^assignment_ids and
              (assignment.request_id not in ^request_ids or assignment.helper_id not in ^user_ids)
      )

    if unexpected?,
      do: Mix.raise("a full staging fixture assignment crosses into non-fixture data")
  end

  defp validate_activity_membership!(activity_ids, user_ids) do
    unexpected_participant? =
      Repo.exists?(
        from participant in Participant,
          where: participant.user_id in ^user_ids and participant.activity_id not in ^activity_ids
      )

    unexpected_message? =
      Repo.exists?(
        from message in ActivityMessage,
          where: message.sender_id in ^user_ids and message.activity_id not in ^activity_ids
      )

    if unexpected_participant? or unexpected_message? do
      Mix.raise("a full staging fixture user is linked to a non-fixture activity")
    end
  end

  defp validate_category_votes!(proposal_ids, user_ids) do
    unexpected? =
      Repo.exists?(
        from vote in CategoryVote,
          where: vote.user_id in ^user_ids and vote.proposal_id not in ^proposal_ids
      )

    if unexpected?, do: Mix.raise("a full staging fixture vote targets a non-fixture proposal")
  end

  defp validate_reviewed_records!(user_ids, report_ids, proposal_ids) do
    unexpected_report? =
      Repo.exists?(
        from report in Report,
          where: report.reviewed_by_id in ^user_ids and report.id not in ^report_ids
      )

    unexpected_proposal? =
      Repo.exists?(
        from proposal in CategoryProposal,
          where: proposal.reviewed_by_id in ^user_ids and proposal.id not in ^proposal_ids
      )

    unexpected_signal? =
      Repo.exists?(
        from signal in AbuseSignal,
          where: signal.reviewed_by_id in ^user_ids and signal.subject_id not in ^user_ids
      )

    if unexpected_report? or unexpected_proposal? or unexpected_signal? do
      Mix.raise("the run-scoped moderator changed a non-fixture record")
    end
  end

  defp validate_audits(targets) do
    allowed =
      [
        {"user", targets.users},
        {"request", targets.requests},
        {"assignment", targets.assignments},
        {"activity", targets.activities},
        {"activity_participant", targets.activity_participants},
        {"category_proposal", targets.proposals},
        {"report", targets.reports},
        {"abuse_signal", targets.signals}
      ]
      |> Enum.flat_map(fn {type, ids} -> Enum.map(ids, &{type, &1}) end)
      |> MapSet.new()

    unexpected =
      AuditEvent
      |> where([event], event.actor_id in ^targets.users)
      |> select([event], {event.target_type, event.target_id, event.action})
      |> Repo.all()
      |> Enum.reject(fn {type, id, _action} -> MapSet.member?(allowed, {type, id}) end)

    if unexpected != [] do
      Mix.raise(
        "run-scoped users produced audit events outside fixture data: #{inspect(unexpected)}"
      )
    end
  end

  defp fixture_push_job_ids(user_ids) do
    recipients = Enum.map(user_ids, &"user:#{&1}")

    Job
    |> where(
      [job],
      job.worker == "WhoNeedHelp.Push.DeliveryWorker" and
        fragment("?->>'recipient' = ANY(?)", job.args, ^recipients)
    )
    |> select([job], job.id)
    |> Repo.all()
  end

  defp assert_prefix_unused!(context) do
    if Repo.exists?(from user in User, where: like(user.email, ^"#{prefix(context.run_id)}%")) do
      Mix.raise("staging E2E users still exist for #{inspect(context.run_id)}")
    end
  end

  defp assert_only_allowed_prefix_users!(context) do
    allowed = Map.values(context.emails)

    unexpected =
      User
      |> where([user], like(user.email, ^"#{prefix(context.run_id)}%"))
      |> where([user], user.email not in ^allowed)
      |> select([user], user.email)
      |> Repo.all()

    if unexpected != [] do
      Mix.raise("unexpected users share the staging run prefix: #{inspect(unexpected)}")
    end
  end

  defp ids(schema, field, parent_ids) do
    schema
    |> where([row], field(row, ^field) in ^parent_ids)
    |> select([row], row.id)
    |> Repo.all()
  end

  defp delete_ids(_schema, []), do: 0

  defp delete_ids(schema, ids) do
    schema
    |> where([row], row.id in ^ids)
    |> delete_count()
  end

  defp delete_count(query), do: query |> Repo.delete_all() |> elem(0)

  defp insert_user!(email, display_name, password_hash, role, now) do
    %User{}
    |> User.registration_changeset(%{
      "email" => email,
      "display_name" => display_name,
      "locale" => "en",
      "terms_accepted" => true
    })
    |> Ecto.Changeset.put_change(:hashed_password, password_hash)
    |> Ecto.Changeset.put_change(:confirmed_at, now)
    |> Ecto.Changeset.put_change(:role, role)
    |> Repo.insert!()
  end

  defp emails(run_id) do
    (@precreated_roles ++ @registered_roles)
    |> Map.new(fn role -> {role, "#{prefix(run_id)}#{role}@example.invalid"} end)
  end

  defp prefix(run_id), do: "wnh-staging-e2e-#{run_id}-"

  defp display_name(role) do
    role
    |> String.replace("-", " ")
    |> String.split()
    |> Enum.map_join(" ", &String.capitalize/1)
    |> then(&"Staging E2E #{&1}")
  end

  defp required_env!(name) do
    case System.get_env(name) do
      value when is_binary(value) and value != "" -> value
      _ -> Mix.raise("#{name} is required")
    end
  end

  defp uuid?(value) when is_binary(value) do
    match?({:ok, _}, Ecto.UUID.cast(value))
  end

  defp uuid?(_value), do: false
end
