defmodule LinuxRollout.Adapters.GitSubmit do
  alias LinuxRollout.{Util, Workspace}

  def submit(workspace, bundle, target, opts) do
    plan_path = Path.join(bundle.bundle_root, "GIT_SUBMIT_PLAN.md")
    units = Workspace.target_units(target)
    write_plan!(plan_path, target, units)

    if Keyword.get(opts, :dry_run, false) do
      %{status: "dry_run", plan: plan_path, units: Enum.map(units, &unit_summary/1)}
    else
      unit_results =
        Enum.map(units, fn unit ->
          Util.witness(
            "git-submit: #{target["name"]}/#{unit["name"]} using #{target["submission_mode"]}"
          )

          submit_unit!(workspace, bundle, target, unit)
        end)

      %{status: "submitted", plan: plan_path, units: unit_results}
    end
  end

  defp submit_unit!(workspace, bundle, target, unit) do
    case target["submission_mode"] do
      "push_only" -> submit_push_only!(workspace, bundle, unit)
      "github_pr" -> submit_github_pr!(workspace, bundle, target, unit)
      "gitlab_mr" -> submit_gitlab_mr!(workspace, bundle, target, unit)
      other -> raise "unsupported git submission mode #{inspect(other)}"
    end
  end

  defp submit_push_only!(workspace, bundle, unit) do
    repo = get_in(unit, ["destination", "repo"]) || raise "missing push destination repo"
    branch = get_in(unit, ["destination", "base_branch"]) || "master"
    checkout_dir = Path.join([workspace.checkout_root, unit["target_name"], unit["name"]])
    git_env = git_env_for_repo(repo)

    Util.witness("git-submit: #{unit["name"]} cloning #{repo}")
    clone_git_repo_if_missing!(repo, checkout_dir, git_env)
    Util.witness("git-submit: #{unit["name"]} updating branch #{branch}")
    ensure_git_branch!(checkout_dir, branch, branch, true)
    apply_path_map!(bundle.source_root, unit["path_map"], checkout_dir)
    commit_if_needed!(checkout_dir, unit["commit_message"])
    Util.witness("git-submit: #{unit["name"]} pushing to #{repo}")
    Util.run!("git", ["push", "origin", "HEAD:#{branch}"], cd: checkout_dir, env: git_env)

    %{name: unit["name"], status: "submitted", repo: repo}
  end

  defp submit_github_pr!(workspace, bundle, target, unit) do
    upstream_repo = get_in(unit, ["destination", "repo"]) || raise "missing GitHub upstream repo"
    fork_repo = get_in(unit, ["destination", "fork_repo"]) || upstream_repo
    base_branch = get_in(unit, ["destination", "base_branch"]) || "main"
    branch_name = unit["branch_name"]
    checkout_dir = Path.join([workspace.checkout_root, unit["target_name"], unit["name"]])

    head_owner =
      Util.split_repo_owner(fork_repo) || raise "cannot determine fork owner for #{fork_repo}"

    ensure_github_repo!(upstream_repo, fork_repo)
    Util.witness("git-submit: #{unit["name"]} cloning #{fork_repo}")
    clone_github_repo_if_missing!(fork_repo, checkout_dir)
    ensure_git_remote!(checkout_dir, "upstream", "https://github.com/#{upstream_repo}.git")
    Util.witness("git-submit: #{unit["name"]} preparing branch #{branch_name}")
    ensure_git_branch!(checkout_dir, base_branch, branch_name, false)
    apply_path_map!(bundle.source_root, unit["path_map"], checkout_dir)
    commit_if_needed!(checkout_dir, unit["commit_message"])
    Util.witness("git-submit: #{unit["name"]} pushing branch #{branch_name}")
    push_branch!(checkout_dir, branch_name)

    head_ref = "#{head_owner}:#{branch_name}"

    case existing_github_pr_url(upstream_repo, [head_ref, branch_name], base_branch) do
      nil ->
        Util.witness("git-submit: #{unit["name"]} opening GitHub PR")

        args = [
          "pr",
          "create",
          "--repo",
          upstream_repo,
          "--base",
          base_branch,
          "--head",
          head_ref,
          "--title",
          unit["commit_message"],
          "--body",
          pr_body(target, unit),
          "--draft"
        ]

        case Util.run("gh", args, cd: checkout_dir) do
          {:ok, output} ->
            %{
              name: unit["name"],
              status: "submitted",
              repo: upstream_repo,
              fork_repo: fork_repo,
              pull_request: github_pr_url_from_output(output)
            }

          {:error, %{output: output}} ->
            case detect_existing_github_pr(
                   output,
                   upstream_repo,
                   head_ref,
                   branch_name,
                   base_branch
                 ) do
              %{pull_request: url} ->
                %{
                  name: unit["name"],
                  status: "submitted",
                  repo: upstream_repo,
                  fork_repo: fork_repo,
                  pull_request: url,
                  existing_pull_request: true
                }

              nil ->
                raise "gh #{Enum.join(args, " ")} failed: #{output}"
            end
        end

      url ->
        %{
          name: unit["name"],
          status: "submitted",
          repo: upstream_repo,
          fork_repo: fork_repo,
          pull_request: url,
          existing_pull_request: true
        }
    end
  end

  defp submit_gitlab_mr!(workspace, bundle, target, unit) do
    upstream_repo = get_in(unit, ["destination", "repo"]) || raise "missing GitLab upstream repo"
    fork_repo = get_in(unit, ["destination", "fork_repo"]) || upstream_repo
    host = get_in(unit, ["destination", "host"]) || "gitlab.com"
    base_branch = get_in(unit, ["destination", "base_branch"]) || "master"
    branch_name = unit["branch_name"]
    checkout_dir = Path.join([workspace.checkout_root, unit["target_name"], unit["name"]])
    glab_env = gitlab_env(host)

    Util.witness("git-submit: #{unit["name"]} ensuring GitLab fork #{fork_repo}")
    ensure_gitlab_fork!(host, upstream_repo, fork_repo, glab_env)
    Util.witness("git-submit: #{unit["name"]} cloning #{fork_repo}")
    clone_gitlab_repo_if_missing!(host, fork_repo, checkout_dir, glab_env)
    ensure_git_remote!(checkout_dir, "upstream", "https://#{host}/#{upstream_repo}.git")
    Util.witness("git-submit: #{unit["name"]} preparing branch #{branch_name}")
    ensure_git_branch!(checkout_dir, base_branch, branch_name, false)
    apply_path_map!(bundle.source_root, unit["path_map"], checkout_dir)
    commit_if_needed!(checkout_dir, unit["commit_message"])
    Util.witness("git-submit: #{unit["name"]} pushing branch #{branch_name}")
    push_branch!(checkout_dir, branch_name)

    Util.witness("git-submit: #{unit["name"]} opening GitLab merge request")

    mr_args = [
      "mr",
      "create",
      "--repo",
      upstream_repo,
      "-H",
      fork_repo,
      "-b",
      base_branch,
      "-s",
      branch_name,
      "-t",
      unit["commit_message"],
      "-d",
      pr_body(target, unit),
      "--draft",
      "--yes"
    ]

    case Util.run("glab", mr_args, cd: checkout_dir, env: glab_env) do
      {:ok, output} ->
        %{
          name: unit["name"],
          status: "submitted",
          repo: upstream_repo,
          fork_repo: fork_repo,
          merge_request: gitlab_mr_url_from_output(output, host, upstream_repo)
        }

      {:error, %{output: output}} when is_binary(output) ->
        case detect_existing_gitlab_mr(output, host, upstream_repo) do
          %{merge_request: url} ->
            %{
              name: unit["name"],
              status: "submitted",
              repo: upstream_repo,
              fork_repo: fork_repo,
              merge_request: url,
              existing_merge_request: true
            }

          nil ->
            raise "glab #{Enum.join(mr_args, " ")} failed: #{output}"
        end
    end
  end

  defp write_plan!(plan_path, target, units) do
    contents =
      [
        "# Git submission plan",
        "",
        "- Target: `#{target["name"]}`",
        "- Mode: `#{target["submission_mode"]}`",
        "- Units:",
        Enum.map(units, fn unit ->
          [
            "  - `#{unit["name"]}`",
            "    - Destination repo: #{get_in(unit, ["destination", "repo"]) || "n/a"}",
            "    - Fork repo: #{get_in(unit, ["destination", "fork_repo"]) || "n/a"}"
          ]
        end),
        ""
      ]
      |> List.flatten()
      |> Enum.join("\n")
      |> Kernel.<>("\n")

    File.write!(plan_path, contents)
  end

  defp clone_git_repo_if_missing!(repo, checkout_dir, env) do
    File.mkdir_p!(Path.dirname(checkout_dir))

    unless File.dir?(checkout_dir) do
      Util.run!("git", ["clone", repo, checkout_dir], env: env)
    end
  end

  defp clone_github_repo_if_missing!(repo, checkout_dir) do
    File.mkdir_p!(Path.dirname(checkout_dir))

    unless File.dir?(checkout_dir) do
      Util.run!("gh", ["repo", "clone", repo, checkout_dir])
    end
  end

  defp clone_gitlab_repo_if_missing!(_host, repo, checkout_dir, env) do
    File.mkdir_p!(Path.dirname(checkout_dir))

    unless File.dir?(checkout_dir) do
      Util.run!("glab", ["repo", "clone", repo, checkout_dir], env: env)
    end
  end

  defp ensure_github_repo!(upstream_repo, fork_repo) do
    if github_repo_exists?(fork_repo) do
      :ok
    else
      current_user = github_current_username!()

      repo_owner =
        Util.split_repo_owner(fork_repo) || raise "cannot determine GitHub owner for #{fork_repo}"

      fork_name = repo_name!(fork_repo)
      upstream_name = repo_name!(upstream_repo)

      cond do
        upstream_repo == fork_repo ->
          create_github_repo!(fork_repo, repo_owner, current_user)

        true ->
          fork_github_repo!(upstream_repo, repo_owner, current_user, fork_name, upstream_name)
      end

      wait_for_github_repo!(fork_repo)
    end
  end

  defp github_repo_exists?(repo) do
    case Util.run("gh", ["api", "repos/#{repo}"]) do
      {:ok, _output} ->
        true

      {:error, %{status: 1, output: output}} when is_binary(output) ->
        if String.contains?(output, "Not Found") or String.contains?(output, "HTTP 404") do
          false
        else
          raise "unable to inspect GitHub repository #{repo}: #{output}"
        end

      {:error, %{output: output}} ->
        raise "unable to inspect GitHub repository #{repo}: #{output}"
    end
  end

  defp wait_for_github_repo!(repo) do
    attempts = 15
    interval_ms = 1000

    if wait_for_github_repo(repo, attempts, interval_ms) do
      :ok
    else
      raise "repository #{repo} was requested on GitHub but did not become visible after #{attempts} seconds"
    end
  end

  defp wait_for_github_repo(_repo, 0, _interval_ms), do: false

  defp wait_for_github_repo(repo, attempts_left, interval_ms) do
    if github_repo_exists?(repo) do
      true
    else
      Process.sleep(interval_ms)
      wait_for_github_repo(repo, attempts_left - 1, interval_ms)
    end
  end

  defp create_github_repo!(repo, repo_owner, current_user) do
    if repo_owner != current_user do
      raise """
      configured GitHub repository #{repo} does not exist, and the authenticated GitHub user #{current_user} \
      cannot auto-create it in namespace #{repo_owner}; create the repository manually or set the destination owner to #{current_user}
      """
    end

    Util.run!("gh", ["repo", "create", repo, "--public", "--add-readme"])
  end

  defp fork_github_repo!(upstream_repo, repo_owner, current_user, fork_name, upstream_name) do
    if fork_name != upstream_name do
      raise """
      auto-fork currently supports GitHub forks that keep the upstream repository name; \
      pre-create #{repo_owner}/#{fork_name} manually or set destination.fork_repo to #{repo_owner}/#{upstream_name}
      """
    end

    fork_args =
      ["api", "repos/#{upstream_repo}/forks", "--method", "POST"] ++
        if(repo_owner == current_user, do: [], else: ["-f", "organization=#{repo_owner}"])

    Util.run!("gh", fork_args)
  end

  defp github_current_username! do
    Util.run!("gh", ["api", "user", "--jq", ".login"])
    |> String.trim()
  end

  defp ensure_gitlab_fork!(_host, upstream_repo, fork_repo, _env) when upstream_repo == fork_repo,
    do: :ok

  defp ensure_gitlab_fork!(host, upstream_repo, fork_repo, env) do
    if gitlab_project_exists?(host, fork_repo, env) do
      :ok
    else
      current_user = gitlab_current_username!(host, env)

      fork_owner =
        Util.split_repo_owner(fork_repo) ||
          raise "cannot determine GitLab fork owner for #{fork_repo}"

      if fork_owner != current_user do
        raise """
        configured GitLab fork #{fork_repo} does not exist, and the authenticated #{host} user #{current_user} \
        cannot auto-create a fork in namespace #{fork_owner}; set accounts.gitlab_owner to #{current_user} or create the fork manually
        """
      end

      fork_name = repo_name!(fork_repo)
      upstream_name = repo_name!(upstream_repo)

      fork_args =
        ["repo", "fork", upstream_repo, "--clone=false", "--remote=false"] ++
          if fork_name == upstream_name, do: [], else: ["--path", fork_name]

      Util.run!("glab", fork_args, env: env)
      wait_for_gitlab_project!(host, fork_repo, env)
    end
  end

  defp gitlab_project_exists?(host, repo, env) do
    endpoint = "projects/#{gitlab_project_id(repo)}"

    case Util.run("glab", ["api", endpoint, "--hostname", host], env: env) do
      {:ok, _output} ->
        true

      {:error, %{status: 1, output: output}} when is_binary(output) ->
        if String.contains?(output, "404 Project Not Found") do
          false
        else
          raise "unable to inspect GitLab project #{repo} on #{host}: #{output}"
        end

      {:error, %{output: output}} ->
        raise "unable to inspect GitLab project #{repo} on #{host}: #{output}"
    end
  end

  defp wait_for_gitlab_project!(host, repo, env) do
    attempts = 15
    interval_ms = 1000

    if wait_for_gitlab_project(repo, host, env, attempts, interval_ms) do
      :ok
    else
      raise "fork #{repo} was requested on #{host} but did not become visible after #{attempts} seconds"
    end
  end

  defp wait_for_gitlab_project(_repo, _host, _env, 0, _interval_ms), do: false

  defp wait_for_gitlab_project(repo, host, env, attempts_left, interval_ms) do
    if gitlab_project_exists?(host, repo, env) do
      true
    else
      Process.sleep(interval_ms)
      wait_for_gitlab_project(repo, host, env, attempts_left - 1, interval_ms)
    end
  end

  defp gitlab_current_username!(host, env) do
    host
    |> gitlab_current_user!(env)
    |> Map.fetch!("username")
  end

  defp gitlab_current_user!(host, env) do
    output = Util.run!("glab", ["api", "user", "--hostname", host], env: env)
    :json.decode(output)
  end

  defp gitlab_project_id(repo) do
    URI.encode_www_form(repo)
  end

  defp gitlab_env(host) do
    [{"GITLAB_HOST", host}]
  end

  defp git_env_for_repo(repo) do
    if String.starts_with?(repo, "ssh://aur@aur.archlinux.org/") or
         String.contains?(repo, "@wip.pkgsrc.org:") do
      [{"GIT_SSH_COMMAND", "ssh -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10"}]
    else
      []
    end
  end

  defp push_branch!(checkout_dir, branch_name) do
    Util.run!(
      "git",
      ["push", "--force-with-lease", "--set-upstream", "origin", branch_name],
      cd: checkout_dir
    )
  end

  @doc false
  def detect_existing_gitlab_mr(output, host, upstream_repo)

  def detect_existing_gitlab_mr(output, host, upstream_repo) when is_binary(output) do
    normalized = normalize_whitespace(output)

    if String.contains?(
         normalized,
         "Another open merge request already exists for this source branch"
       ) do
      iid = gitlab_existing_mr_iid!(normalized)
      %{merge_request: "https://#{host}/#{upstream_repo}/-/merge_requests/#{iid}"}
    else
      nil
    end
  end

  @doc false
  def detect_existing_github_pr(output, repo, head_ref, branch_name, base_branch)

  def detect_existing_github_pr(output, repo, head_ref, branch_name, base_branch)
      when is_binary(output) do
    normalized = normalize_whitespace(output)

    if String.contains?(normalized, "already exists: https://github.com/") or
         String.contains?(normalized, "pull request already exists") or
         String.contains?(normalized, "pull request for branch") or
         String.contains?(normalized, "A pull request already exists for") do
      case github_pr_url_from_output(output) do
        url when is_binary(url) ->
          %{pull_request: url}

        _ ->
          case existing_github_pr_url(repo, [head_ref, branch_name], base_branch) do
            nil -> nil
            url -> %{pull_request: url}
          end
      end
    else
      nil
    end
  end

  defp gitlab_existing_mr_iid!(output) do
    case Regex.run(~r/source branch:\s*!(\d+)/, output, capture: :all_but_first) do
      [iid] -> iid
      _ -> raise "could not determine existing GitLab merge request IID from: #{output}"
    end
  end

  defp normalize_whitespace(output) do
    output
    |> String.replace(~r/\s+/, " ")
    |> String.trim()
  end

  defp existing_github_pr_url(repo, head_refs, base_branch) when is_list(head_refs) do
    head_refs
    |> Enum.uniq()
    |> Enum.find_value(&existing_github_pr_url(repo, &1, base_branch))
  end

  defp existing_github_pr_url(repo, head_ref, base_branch) do
    case Util.run("gh", [
           "pr",
           "list",
           "--repo",
           repo,
           "--head",
           head_ref,
           "--base",
           base_branch,
           "--state",
           "open",
           "--json",
           "url"
         ]) do
      {:ok, output} ->
        case :json.decode(output) do
          [%{"url" => url} | _] when is_binary(url) and url != "" -> url
          _ -> nil
        end

      {:error, %{output: output}} ->
        raise "unable to inspect existing GitHub pull requests for #{head_ref} in #{repo}: #{output}"
    end
  end

  defp github_pr_url_from_output(output) when is_binary(output) do
    case Regex.run(~r|https://github\.com/[^\s]+/pull/\d+|, output) do
      [url] -> url
      _ -> nil
    end
  end

  defp gitlab_mr_url_from_output(output, host, upstream_repo) when is_binary(output) do
    case Regex.run(~r|https://#{Regex.escape(host)}/[^\s]+/-/merge_requests/\d+|, output) do
      [url] -> url
      _ -> "https://#{host}/#{upstream_repo}/-/merge_requests"
    end
  end

  defp repo_name!(repo) do
    repo
    |> String.split("/", parts: 2)
    |> List.last()
    |> case do
      nil -> raise "cannot determine repository name for #{repo}"
      "" -> raise "cannot determine repository name for #{repo}"
      name -> name
    end
  end

  defp ensure_git_remote!(checkout_dir, remote_name, remote_url) do
    case Util.run("git", ["remote", "get-url", remote_name], cd: checkout_dir) do
      {:ok, _url} ->
        :ok

      {:error, _reason} ->
        Util.run!("git", ["remote", "add", remote_name, remote_url], cd: checkout_dir)
    end
  end

  defp ensure_git_branch!(checkout_dir, base_branch, branch_name, allow_orphan) do
    Util.run("git", ["fetch", "--all", "--prune"], cd: checkout_dir)

    case Util.run("git", ["checkout", base_branch], cd: checkout_dir) do
      {:ok, _output} ->
        :ok

      {:error, _reason} when allow_orphan ->
        Util.run!("git", ["checkout", "--orphan", base_branch], cd: checkout_dir)

      {:error, _reason} ->
        Util.run!("git", ["checkout", "-B", base_branch, "origin/#{base_branch}"],
          cd: checkout_dir
        )
    end

    Util.run("git", ["pull", "--ff-only", "origin", base_branch], cd: checkout_dir)
    Util.run!("git", ["checkout", "-B", branch_name], cd: checkout_dir)
  end

  defp apply_path_map!(bundle_source_root, path_map, checkout_dir) do
    Enum.each(path_map, fn {source_relative, destination_relative} ->
      source_path = Path.join(bundle_source_root, source_relative)
      destination_path = Path.join(checkout_dir, destination_relative)

      if File.exists?(source_path) do
        Util.copy_file!(source_path, destination_path)
      else
        raise "missing bundled source file #{source_path}"
      end
    end)
  end

  defp commit_if_needed!(checkout_dir, message) do
    Util.run!("git", ["add", "-A"], cd: checkout_dir)

    case System.cmd("git", ["diff", "--cached", "--quiet"], cd: checkout_dir) do
      {_output, 0} ->
        :ok

      {_output, 1} ->
        Util.run!("git", ["commit", "-m", message], cd: checkout_dir)
    end
  end

  defp unit_summary(unit) do
    %{
      name: unit["name"],
      repo: get_in(unit, ["destination", "repo"]),
      fork_repo: get_in(unit, ["destination", "fork_repo"])
    }
  end

  defp pr_body(target, unit) do
    [
      "Prepared by the local WireShare Linux rollout orchestrator.",
      "",
      "- Target: `#{target["name"]}`",
      "- Unit: `#{unit["name"]}`",
      "- Submission mode: `#{target["submission_mode"]}`"
    ]
    |> Enum.join("\n")
  end
end
