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

  import Ecto.Query

  alias WhoNeedHelp.Accounts.{Scope, User}
  alias WhoNeedHelp.Catalog
  alias WhoNeedHelp.Help
  alias WhoNeedHelp.Help.{Assignment, HelpRequest}
  alias WhoNeedHelp.Messaging.Message
  alias WhoNeedHelp.Repo
  alias WhoNeedHelp.Tracking.{Position, TrackingSession}
  alias WhoNeedHelp.Trust.{AbuseSignal, AuditEvent, Report, Review}

  @shortdoc "Prepares or removes isolated authenticated load fixtures"
  @confirmation "isolated-load-fixtures"

  @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.load_fixtures prepare|cleanup")
  end

  defp verified_context do
    run_id = required_env!("WNH_LOAD_FIXTURE_RUN_ID")
    expected_database = required_env!("WNH_LOAD_EXPECTED_DATABASE")
    manifest_path = required_env!("WNH_LOAD_FIXTURE_PATH") |> Path.expand()

    unless Regex.match?(~r/^[A-Za-z0-9._-]+$/, run_id) do
      Mix.raise(
        "WNH_LOAD_FIXTURE_RUN_ID may contain only letters, numbers, dot, underscore, and dash"
      )
    end

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

    unless String.starts_with?(manifest_path, "/output/") do
      Mix.raise("WNH_LOAD_FIXTURE_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 load-fixture mutation: expected database #{inspect(expected_database)}, " <>
          "observed #{inspect(actual_database)}"
      )
    end

    %{
      run_id: run_id,
      database: actual_database,
      manifest_path: manifest_path
    }
  end

  defp prepare(context) do
    count = positive_integer_env!("WNH_LOAD_FIXTURE_COUNT")
    password = required_env!("LOAD_FIXTURE_PASSWORD")

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

    email_prefix = email_prefix(context.run_id)

    if Repo.exists?(
         from(user in User,
           where: fragment("left(?, ?) = ?", user.email, ^byte_size(email_prefix), ^email_prefix)
         )
       ) do
      Mix.raise("fixtures already exist for run #{inspect(context.run_id)}")
    end

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

    {:ok, fixtures} =
      Repo.transaction(fn ->
        Enum.map(1..count, fn index ->
          requester =
            insert_user!(
              "#{email_prefix}requester-#{index}@example.invalid",
              "Load requester #{index}",
              password_hash,
              now
            )

          helper =
            insert_user!(
              "#{email_prefix}helper-#{index}@example.invalid",
              "Load helper #{index}",
              password_hash,
              now
            )

          latitude = 50.45 + index / 100_000
          longitude = 30.52 + index / 100_000

          request =
            Help.create_request(Scope.for_user(requester), %{
              "title" => "Load medicine pickup #{context.run_id} #{index}",
              "description" =>
                "Isolated authenticated load fixture for a reserved legal medicine pickup.",
              "pickup_instructions" => "Synthetic load fixture; no real pickup.",
              "structured_data" => %{"pickup_status" => "reserved"},
              "location_label" => "Isolated load location #{index}",
              "latitude" => Float.to_string(latitude),
              "longitude" => Float.to_string(longitude),
              "urgency" => "now",
              "location_visibility" => "exact_for_active_match",
              "expires_at" => DateTime.add(now, 86_400, :second),
              "category_id" => category.id,
              "safety_confirmed" => true
            })
            |> unwrap!("create request")

          assignment =
            Help.accept_request(Scope.for_user(helper), request.id)
            |> unwrap!("accept request")

          %{
            "index" => index,
            "requester_id" => requester.id,
            "helper_id" => helper.id,
            "helper_email" => helper.email,
            "request_id" => request.id,
            "assignment_id" => assignment.id,
            "latitude" => latitude,
            "longitude" => longitude
          }
        end)
      end)

    manifest = %{
      "schema_version" => 1,
      "run_id" => context.run_id,
      "database" => context.database,
      "fixtures" => fixtures
    }

    context.manifest_path
    |> Path.dirname()
    |> File.mkdir_p!()

    File.write!(context.manifest_path, Jason.encode_to_iodata!(manifest, pretty: true))
    # The parent evidence directory is mode 0700 on the host. The manifest
    # contains only temporary IDs and emails (never the password) and must be
    # readable by the separately containerized, unprivileged k6 process.
    File.chmod!(context.manifest_path, 0o644)

    Mix.shell().info(
      "prepared #{length(fixtures)} fixture pairs for #{context.run_id} in #{context.database}"
    )
  end

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

    unless manifest["schema_version"] == 1 and manifest["run_id"] == context.run_id and
             manifest["database"] == context.database do
      Mix.raise("fixture manifest does not match the requested run and database")
    end

    fixtures = manifest["fixtures"]

    unless is_list(fixtures) and fixtures != [] do
      Mix.raise("fixture manifest has no non-empty fixture list")
    end

    user_ids = Enum.flat_map(fixtures, &[&1["requester_id"], &1["helper_id"]])
    request_ids = Enum.map(fixtures, & &1["request_id"])
    assignment_ids = Enum.map(fixtures, & &1["assignment_id"])

    unless Enum.all?(user_ids ++ request_ids ++ assignment_ids, &uuid?/1) do
      Mix.raise("fixture manifest contains an invalid identifier")
    end

    validate_manifest_ownership!(context, fixtures)

    {:ok, deleted} =
      Repo.transaction(fn ->
        tracking_session_ids =
          TrackingSession
          |> where([session], session.assignment_id in ^assignment_ids)
          |> select([session], session.id)
          |> Repo.all()

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

        report_count =
          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
          )
          |> Repo.delete_all()
          |> elem(0)

        audit_count =
          AuditEvent
          |> where(
            [event],
            event.actor_id in ^user_ids or
              (event.target_type == "request" and event.target_id in ^request_ids) or
              (event.target_type == "assignment" and event.target_id in ^assignment_ids)
          )
          |> Repo.delete_all()
          |> elem(0)

        position_count =
          Position
          |> where([position], position.tracking_session_id in ^tracking_session_ids)
          |> Repo.delete_all()
          |> elem(0)

        session_count =
          TrackingSession
          |> where([session], session.id in ^tracking_session_ids)
          |> Repo.delete_all()
          |> elem(0)

        message_count =
          Message
          |> where([message], message.assignment_id in ^assignment_ids)
          |> Repo.delete_all()
          |> elem(0)

        abuse_count =
          AbuseSignal
          |> where(
            [signal],
            signal.subject_id in ^user_ids or signal.assignment_id in ^assignment_ids
          )
          |> Repo.delete_all()
          |> elem(0)

        review_count =
          Review
          |> where(
            [review],
            review.assignment_id in ^assignment_ids or review.reviewer_id in ^user_ids or
              review.reviewee_id in ^user_ids
          )
          |> Repo.delete_all()
          |> elem(0)

        assignment_count =
          Assignment
          |> where([assignment], assignment.id in ^assignment_ids)
          |> Repo.delete_all()
          |> elem(0)

        request_count =
          HelpRequest
          |> where([request], request.id in ^request_ids)
          |> Repo.delete_all()
          |> elem(0)

        user_count =
          User
          |> where([user], user.id in ^user_ids)
          |> Repo.delete_all()
          |> elem(0)

        %{
          reports: report_count,
          audit_events: audit_count,
          tracking_positions: position_count,
          tracking_sessions: session_count,
          messages: message_count,
          abuse_signals: abuse_count,
          reviews: review_count,
          assignments: assignment_count,
          requests: request_count,
          users: user_count
        }
      end)

    Mix.shell().info(
      "removed fixtures for #{context.run_id} from #{context.database}: #{inspect(deleted)}"
    )
  end

  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 validate_manifest_ownership!(context, fixtures) do
    indexes = Enum.map(fixtures, & &1["index"])
    expected_indexes = Enum.to_list(1..length(fixtures))

    unless Enum.all?(indexes, &(is_integer(&1) and &1 > 0)) and
             Enum.sort(indexes) == expected_indexes do
      Mix.raise("fixture manifest indexes are not the exact prepared sequence")
    end

    prefix = email_prefix(context.run_id)

    expected_users =
      fixtures
      |> Enum.flat_map(fn fixture ->
        requester_email = "#{prefix}requester-#{fixture["index"]}@example.invalid"
        helper_email = "#{prefix}helper-#{fixture["index"]}@example.invalid"

        unless fixture["helper_email"] == helper_email do
          Mix.raise("fixture manifest helper identity does not match its run and index")
        end

        [
          {fixture["requester_id"], requester_email},
          {fixture["helper_id"], helper_email}
        ]
      end)
      |> MapSet.new()

    expected_requests =
      fixtures
      |> Enum.map(&{&1["request_id"], &1["requester_id"]})
      |> MapSet.new()

    expected_assignments =
      fixtures
      |> Enum.map(&{&1["assignment_id"], &1["request_id"], &1["helper_id"]})
      |> MapSet.new()

    user_ids = Enum.map(expected_users, &elem(&1, 0))
    request_ids = Enum.map(expected_requests, &elem(&1, 0))
    assignment_ids = Enum.map(expected_assignments, &elem(&1, 0))

    observed_users =
      User
      |> where([user], user.id in ^user_ids)
      |> select([user], {user.id, user.email})
      |> Repo.all()
      |> MapSet.new()

    observed_requests =
      HelpRequest
      |> where([request], request.id in ^request_ids)
      |> select([request], {request.id, request.requester_id})
      |> Repo.all()
      |> MapSet.new()

    observed_assignments =
      Assignment
      |> where([assignment], assignment.id in ^assignment_ids)
      |> select(
        [assignment],
        {assignment.id, assignment.request_id, assignment.helper_id}
      )
      |> Repo.all()
      |> MapSet.new()

    expected_pair_count = length(fixtures)

    unless MapSet.size(expected_users) == expected_pair_count * 2 and
             MapSet.size(expected_requests) == expected_pair_count and
             MapSet.size(expected_assignments) == expected_pair_count and
             observed_users == expected_users and
             observed_requests == expected_requests and
             observed_assignments == expected_assignments do
      Mix.raise("fixture manifest ownership does not exactly match the load database")
    end
  end

  defp unwrap!({:ok, value}, _operation), do: value

  defp unwrap!({:error, reason}, operation) do
    Repo.rollback("#{operation} failed: #{inspect(reason)}")
  end

  defp positive_integer_env!(name) do
    case Integer.parse(required_env!(name)) do
      {value, ""} when value > 0 -> value
      _ -> Mix.raise("#{name} must be a positive integer")
    end
  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 email_prefix(run_id), do: "wnh-load-#{String.downcase(run_id)}-"

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

  defp uuid?(_value), do: false
end
