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

  import Ecto.Query

  alias Oban.Job
  alias WhoNeedHelp.Accounts.{User, UserToken}
  alias WhoNeedHelp.Catalog.Category
  alias WhoNeedHelp.Help.{Assignment, HelpRequest}
  alias WhoNeedHelp.Messaging.Message
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Tracking.{Position, TrackingSession}
  alias WhoNeedHelp.Trust.{AuditEvent, RateLimitBucket}

  @shortdoc "Prepares or removes an exact Android/public-staging cross-client fixture"
  @confirmation "public-staging-android-e2e"
  @roles ~w(requester helper)

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

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

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

  defp verified_context do
    run_id = required_env!("WNH_ANDROID_E2E_RUN_ID")
    expected_database = required_env!("WNH_ANDROID_E2E_EXPECTED_DATABASE")
    manifest_path = required_env!("WNH_ANDROID_E2E_MANIFEST_PATH") |> Path.expand()

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

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

    unless String.starts_with?(manifest_path, "/output/") do
      Mix.raise("WNH_ANDROID_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 Android 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: Map.new(@roles, &{&1, "#{prefix(run_id)}#{&1}@example.invalid"})
    }
  end

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

    unless byte_size(password) in 12..72 do
      Mix.raise("WNH_ANDROID_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)
    category = Repo.get_by!(Category, slug: "medicine-pickup", active: true)

    {:ok, fixture} =
      Repo.transaction(fn ->
        users =
          Map.new(@roles, fn role ->
            user =
              insert_user!(
                context.emails[role],
                "Android E2E #{String.capitalize(role)}",
                password_hash,
                now
              )

            {role, user}
          end)

        request =
          %HelpRequest{requester_id: users["requester"].id}
          |> HelpRequest.create_changeset(%{
            "title" => "Android cross-client medicine pickup #{context.run_id}",
            "description" =>
              "Run-scoped medicine request for Android and browser interoperability.",
            "structured_data" => %{"pickup_status" => "reserved"},
            "location_label" => "Android E2E central district",
            "latitude" => "50.4501",
            "longitude" => "30.5234",
            "urgency" => "now",
            "location_visibility" => "exact_for_active_match",
            "expires_at" => DateTime.add(now, 3 * 60 * 60, :second),
            "category_id" => category.id,
            "safety_confirmed" => true
          })
          |> Ecto.Changeset.put_change(:status, :matched)
          |> Repo.insert!()

        assignment =
          %Assignment{}
          |> Assignment.changeset(%{
            request_id: request.id,
            helper_id: users["helper"].id,
            status: :accepted,
            accepted_at: now,
            handover_code_hash: :crypto.hash(:sha256, context.run_id)
          })
          |> Repo.insert!()

        {helper_login_token, helper_login_record} =
          UserToken.build_email_token(users["helper"], "login")

        Repo.insert!(helper_login_record)

        %{
          users: users,
          request: request,
          assignment: assignment,
          helper_login_token: helper_login_token
        }
      end)

    manifest = %{
      "schema_version" => 1,
      "run_id" => context.run_id,
      "database" => context.database,
      "users" =>
        Map.new(fixture.users, fn {role, user} ->
          {role, %{"id" => user.id, "email" => user.email}}
        end),
      "request" => %{
        "id" => fixture.request.id,
        "path" => "/requests/#{fixture.request.id}"
      },
      "assignment" => %{"id" => fixture.assignment.id},
      "helper_login_path" => "/users/log-in#token=#{fixture.helper_login_token}"
    }

    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 Android staging E2E fixture 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_users!(manifest, users)

    user_ids = Enum.map(users, & &1.id)
    request_id = manifest["request"]["id"]
    assignment_id = manifest["assignment"]["id"]
    requester_id = manifest["users"]["requester"]["id"]
    helper_id = manifest["users"]["helper"]["id"]

    validate_fixture_links!(request_id, assignment_id, requester_id, helper_id)

    message_ids =
      Message
      |> where([message], message.assignment_id == ^assignment_id)
      |> select([message], message.id)
      |> Repo.all()

    tracking_session_ids =
      TrackingSession
      |> where([session], session.assignment_id == ^assignment_id)
      |> select([session], session.id)
      |> Repo.all()

    validate_interactions!(assignment_id, user_ids)

    job_ids = fixture_push_job_ids(user_ids, assignment_id)
    scope_hashes = Enum.map(user_ids, &:crypto.hash(:sha256, &1))

    {:ok, deleted} =
      Repo.transaction(fn ->
        %{
          push_jobs: delete_ids(Job, job_ids),
          rate_limit_buckets:
            RateLimitBucket
            |> where([bucket], bucket.scope_hash in ^scope_hashes)
            |> delete_count(),
          audit_events:
            AuditEvent
            |> where([event], event.actor_id in ^user_ids)
            |> delete_count(),
          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),
          assignment: delete_ids(Assignment, [assignment_id]),
          request: delete_ids(HelpRequest, [request_id]),
          users:
            User
            |> where([user], user.id in ^user_ids)
            |> delete_count()
        }
      end)

    unless deleted.users == length(@roles) and deleted.assignment == 1 and deleted.request == 1 do
      Mix.raise("Android staging E2E cleanup did not remove the exact fixture")
    end

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

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

    request_id = manifest["request"]["id"]
    assignment_id = manifest["assignment"]["id"]
    requester_id = manifest["users"]["requester"]["id"]
    helper_id = manifest["users"]["helper"]["id"]
    android_message = required_env!("WNH_ANDROID_E2E_ANDROID_MESSAGE")
    browser_reply = required_env!("WNH_ANDROID_E2E_BROWSER_REPLY")

    validate_fixture_links!(request_id, assignment_id, requester_id, helper_id)
    validate_interactions!(assignment_id, [requester_id, helper_id])

    messages =
      Message
      |> where([message], message.assignment_id == ^assignment_id)
      |> select([message], {message.sender_id, message.body})
      |> Repo.all()
      |> Enum.sort()

    expected_messages =
      [{helper_id, android_message}, {requester_id, browser_reply}]
      |> Enum.sort()

    unless messages == expected_messages do
      Mix.raise("expected the exact Android/browser messages, observed #{inspect(messages)}")
    end

    sessions =
      TrackingSession
      |> where(
        [session],
        session.assignment_id == ^assignment_id and session.user_id == ^helper_id
      )
      |> Repo.all()

    valid_session? =
      match?(
        [
          %TrackingSession{
            active: false,
            ended_at: %DateTime{},
            sample_count: sample_count
          }
        ]
        when sample_count >= 1,
        sessions
      )

    position_count =
      Position
      |> join(:inner, [position], session in TrackingSession,
        on: session.id == position.tracking_session_id
      )
      |> where([_position, session], session.assignment_id == ^assignment_id)
      |> Repo.aggregate(:count)

    unless valid_session? and position_count == 0 do
      Mix.raise(
        "expected one stopped helper tracking session with samples and no retained position"
      )
    end

    verification_path =
      context.manifest_path
      |> Path.dirname()
      |> Path.join("fixture-verification.json")

    File.write!(
      verification_path,
      Jason.encode_to_iodata!(
        %{
          "run_id" => context.run_id,
          "request_id" => request_id,
          "assignment_id" => assignment_id,
          "message_count" => length(messages),
          "tracking_session_count" => length(sessions),
          "tracking_sample_count" => hd(sessions).sample_count,
          "active_tracking_sessions" => 0,
          "retained_positions" => position_count
        },
        pretty: true
      )
    )

    File.chmod!(verification_path, 0o600)
    Mix.shell().info("verified exact Android/browser staging E2E fixture")
  end

  defp validate_manifest!(context, manifest) do
    valid_users? =
      is_map(manifest["users"]) and
        Enum.all?(@roles, fn role ->
          case manifest["users"][role] do
            %{"id" => id, "email" => email} ->
              uuid?(id) and email == context.emails[role]

            _other ->
              false
          end
        end)

    valid_request? =
      match?(
        %{"id" => id, "path" => path} when is_binary(id) and is_binary(path),
        manifest["request"]
      ) and uuid?(manifest["request"]["id"]) and
        manifest["request"]["path"] == "/requests/#{manifest["request"]["id"]}"

    valid_assignment? =
      match?(%{"id" => id} when is_binary(id), manifest["assignment"]) and
        uuid?(manifest["assignment"]["id"])

    unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
             manifest["database"] == context.database and valid_users? and valid_request? and
             valid_assignment? and is_binary(manifest["helper_login_path"]) and
             String.starts_with?(manifest["helper_login_path"], "/users/log-in#token=") do
      Mix.raise("Android staging E2E manifest does not match the requested run and database")
    end
  end

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

    unless length(users) == length(@roles) and
             Enum.all?(@roles, fn role ->
               expected = manifest["users"][role]
               observed[expected["email"]] == expected["id"]
             end) do
      Mix.raise("Android staging E2E user ownership does not match the manifest")
    end
  end

  defp validate_fixture_links!(request_id, assignment_id, requester_id, helper_id) do
    request = Repo.get(HelpRequest, request_id)
    assignment = Repo.get(Assignment, assignment_id)

    valid? =
      match?(%HelpRequest{}, request) and match?(%Assignment{}, assignment) and
        request.requester_id == requester_id and assignment.request_id == request_id and
        assignment.helper_id == helper_id and helper_id != requester_id

    unless valid?, do: Mix.raise("Android staging E2E fixture links no longer match the manifest")
  end

  defp validate_interactions!(assignment_id, user_ids) do
    unexpected_message? =
      Repo.exists?(
        from(message in Message,
          where:
            (message.assignment_id == ^assignment_id and message.sender_id not in ^user_ids) or
              (message.sender_id in ^user_ids and message.assignment_id != ^assignment_id)
        )
      )

    unexpected_tracking? =
      Repo.exists?(
        from(session in TrackingSession,
          where:
            (session.assignment_id == ^assignment_id and session.user_id not in ^user_ids) or
              (session.user_id in ^user_ids and session.assignment_id != ^assignment_id)
        )
      )

    if unexpected_message? or unexpected_tracking? do
      Mix.raise("Android staging fixture users interacted with non-fixture data")
    end
  end

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

    Job
    |> where([job], job.worker == "WhoNeedHelp.Push.DeliveryWorker")
    |> where(
      [job],
      fragment("?->>'recipient' = ANY(?)", job.args, ^recipients) or
        fragment("?->>'assignment_id' = ?", job.args, ^assignment_id)
    )
    |> 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("Android 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 Android staging run prefix: #{inspect(unexpected)}")
    end
  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, 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)
    |> Repo.insert!()
  end

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

  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))
  defp uuid?(_value), do: false
end
