defmodule TarakanWeb.API.ScanController do
  use TarakanWeb, :controller

  alias Tarakan.Repositories
  alias Tarakan.Scans
  alias Tarakan.FindingMemory
  alias Tarakan.FindingSignals
  alias Tarakan.Suppressions

  @doc """
  Records a review submitted by a contributor or external harness.

  Every submission self-reports its provenance (`agent`, `human`, or `hybrid`)
  and review kind. Agent and hybrid reviews also identify the model and prompt
  version. The document is required so a client bug cannot silently record an
  empty result. Self-reported provenance is not an identity attestation and is
  never sufficient for reputation or publication on its own.
  """
  def create(conn, %{"host" => host_slug, "owner" => owner, "name" => name} = params) do
    scope = conn.assigns.current_scope

    with {:repository, %{} = repository} <-
           {:repository, visible_repository(host_slug, owner, name, scope)},
         {:document, {:ok, findings_json}} <- {:document, encode_document(params)} do
      attrs = %{
        "commit_sha" => params["commit_sha"],
        "model" => params["model"],
        "prompt_version" => params["prompt_version"],
        "run_id" => params["run_id"],
        "provenance" => params["provenance"] || "agent",
        "review_kind" => params["review_kind"] || "code_review",
        "notes" => params["notes"],
        "findings_json" => findings_json
      }

      case Tarakan.Reports.publish_report(scope, repository, attrs) do
        {:ok, scan} ->
          conn
          |> put_status(:created)
          |> json(scan_json(scan, repository))

        {:error, :commit_not_found} ->
          unprocessable(conn, %{commit_sha: ["commit not found in this repository on GitHub"]})

        {:error, reason} when reason in [:identity_changed, :not_public, :commit_mismatch] ->
          unprocessable(conn, %{
            commit_sha: ["repository identity or commit could not be bound safely on GitHub"]
          })

        {:error, :rate_limited} ->
          upstream_unavailable(conn, "GitHub is rate limiting requests; try again shortly")

        {:error, :unavailable} ->
          upstream_unavailable(conn, "GitHub could not be reached; try again shortly")

        {:error, :unauthorized} ->
          conn
          |> put_status(:forbidden)
          |> json(%{error: "this account is not authorized to submit reviews"})

        {:error, :submission_limit} ->
          conn
          |> put_resp_header("retry-after", "86400")
          |> put_status(:too_many_requests)
          |> json(%{error: "daily review submission limit reached"})

        {:error, :submission_rate_limited} ->
          conn
          |> put_resp_header("retry-after", "60")
          |> put_status(:too_many_requests)
          |> json(%{error: "review submission rate exceeded"})

        {:error, :secrets_detected} ->
          unprocessable(conn, %{
            document: ["remove secrets or credentials before publishing a report"]
          })

        {:error, %Ecto.Changeset{} = changeset} ->
          unprocessable(conn, changeset_errors(changeset))
      end
    else
      {:repository, nil} ->
        conn
        |> put_status(:not_found)
        |> json(%{error: "repository is not registered with Tarakan"})

      {:document, :error} ->
        unprocessable(conn, %{document: ["is required and must be a Tarakan Scan Format object"]})
    end
  end

  @doc """
  Returns compact, prompt-safe canonical finding memory for a repository.

  Ships the suppression corpus alongside it so a client can tell its agent
  what has already been judged a non-bug here before spending tokens
  rediscovering it.
  """
  def memory(conn, %{"host" => host_slug, "owner" => owner, "name" => name} = params) do
    case visible_repository(host_slug, owner, name, conn.assigns.current_scope) do
      %{} = repository ->
        findings =
          repository
          |> FindingMemory.list_repository_memory(limit: 300)
          |> Enum.map(&memory_finding_json(&1, params["commit_sha"]))

        suppressions = Suppressions.for_repository(repository, limit: 200)

        json(conn, %{
          repository: "#{repository.owner}/#{repository.name}",
          target_commit_sha: params["commit_sha"],
          findings: findings,
          suppressions: %{
            note:
              "Findings the record already judged non-bugs. Do not re-report a " <>
                "suppressed finding unless you have new evidence that changes the verdict.",
            repository: suppressions.repository,
            patterns: suppressions.patterns
          }
        })

      nil ->
        conn |> put_status(:not_found) |> json(%{error: "repository is not registered"})
    end
  end

  @doc "Records a confirm, dispute, or fixed verdict on one canonical finding."
  def finding_verdict(
        conn,
        %{
          "host" => host_slug,
          "owner" => owner,
          "name" => name,
          "public_id" => public_id
        } = params
      ) do
    scope = conn.assigns.current_scope

    case visible_repository(host_slug, owner, name, scope) do
      nil ->
        conn |> put_status(:not_found) |> json(%{error: "repository is not registered"})

      repository ->
        attrs = %{
          "commit_sha" => params["commit_sha"],
          "verdict" => params["verdict"],
          "provenance" => params["provenance"] || "agent",
          "notes" => params["notes"],
          "evidence" => params["evidence"],
          # An adversarial check was asked to break the finding. Reported by the
          # worker because only it knows which prompt it ran.
          "adversarial" => params["adversarial"] == true,
          # Client-attested reproduction: the worker ran the PoC itself.
          "repro_status" => params["repro_status"],
          "repro_runtime" => params["repro_runtime"],
          "repro_artifact" => params["repro_artifact"],
          "repro_transcript" => params["repro_transcript"],
          "client_ip" => TarakanWeb.Plugs.ClientIp.remote_ip_string(conn)
        }

        case FindingMemory.record_check(scope, repository, public_id, attrs) do
          {:ok, _check, canonical} ->
            conn |> put_status(:created) |> json(canonical_finding_json(canonical))

          {:error, :not_found} ->
            conn |> put_status(:not_found) |> json(%{error: "canonical finding not found"})

          {:error, :commit_not_found} ->
            unprocessable(conn, %{commit_sha: ["finding was not observed at this commit"]})

          {:error, :conflict_of_interest} ->
            conn
            |> put_status(:conflict)
            |> json(%{error: "a finding submitter cannot independently verify it"})

          {:error, :secrets_detected} ->
            unprocessable(conn, %{
              notes: ["remove secrets or credentials before submitting a check"]
            })

          {:error, :unauthorized} ->
            conn
            |> put_status(:forbidden)
            |> json(%{error: "this credential is not authorized to verify findings"})

          {:error, %Ecto.Changeset{} = changeset} ->
            unprocessable(conn, changeset_errors(changeset))
        end
    end
  end

  @doc """
  Lists reviews visible to the caller. A reviewer-tier credential with
  `reviews:read` sees restricted findings; other callers see only disclosed
  reviews, redacted per the platform's disclosure rules.
  """
  def index(conn, %{"host" => host_slug, "owner" => owner, "name" => name}) do
    scope = conn.assigns.current_scope

    case visible_repository(host_slug, owner, name, scope) do
      %{} = repository ->
        # Repository visibility is resolved the same way as the web record;
        # sensitive findings are additionally gated per-scan by list_scans.
        scans = Scans.list_scans(scope, repository)
        encoded = Enum.map(scans, &scan_summary_json/1)
        json(conn, %{reports: encoded})

      nil ->
        conn
        |> put_status(:not_found)
        |> json(%{error: "repository is not registered with Tarakan"})
    end
  end

  @doc """
  Records a verdict (and optional proof-of-concept) on a review. The caller
  must be an independent qualified reviewer and not the review's submitter.
  """
  def verdict(conn, %{"host" => host_slug, "owner" => owner, "name" => name, "id" => id} = params) do
    scope = conn.assigns.current_scope

    with {:repository, %{} = repository} <-
           {:repository, visible_repository(host_slug, owner, name, scope)},
         {:scan_id, {scan_id, ""}} <- {:scan_id, Integer.parse(id)},
         {:scan, {:ok, scan}} <- {:scan, Scans.get_scan(scope, scan_id)},
         {:owned, true} <- {:owned, scan.repository_id == repository.id} do
      attrs = %{
        "verdict" => params["verdict"],
        "provenance" => params["provenance"] || "agent",
        "notes" => params["notes"],
        "evidence" => params["evidence"],
        "client_ip" => TarakanWeb.Plugs.ClientIp.remote_ip_string(conn)
      }

      case Scans.record_confirmation(scope, scan, attrs) do
        {:ok, _confirmation} ->
          {:ok, updated} = Scans.get_scan(scope, scan_id)
          conn |> put_status(:created) |> json(scan_summary_json(updated))

        {:error, :conflict_of_interest} ->
          conn
          |> put_status(:conflict)
          |> json(%{error: "the submitter of a review cannot verify it"})

        {:error, :secrets_detected} ->
          unprocessable(conn, %{
            notes: ["remove secrets or credentials before submitting a check"]
          })

        {:error, :unauthorized} ->
          conn
          |> put_status(:forbidden)
          |> json(%{error: "this credential is not authorized to verify reviews"})

        {:error, %Ecto.Changeset{} = changeset} ->
          unprocessable(conn, changeset_errors(changeset))

        {:error, reason} ->
          upstream_unavailable(conn, "verdict could not be recorded (#{inspect(reason)})")
      end
    else
      {:repository, nil} ->
        conn |> put_status(:not_found) |> json(%{error: "repository is not registered"})

      {:scan_id, _invalid} ->
        conn |> put_status(:not_found) |> json(%{error: "review not found"})

      {:scan, {:error, :not_found}} ->
        conn |> put_status(:not_found) |> json(%{error: "review not found"})

      {:owned, false} ->
        conn |> put_status(:not_found) |> json(%{error: "review not found for this repository"})
    end
  end

  defp visible_repository(host_slug, owner, name, scope) do
    case Tarakan.Hosts.host_for_slug(host_slug) do
      {:ok, host} -> Repositories.get_visible_repository(host, owner, name, scope)
      :error -> nil
    end
  end

  defp scan_summary_json(scan) do
    %{
      id: scan.id,
      commit_sha: scan.commit_sha,
      provenance: scan.provenance,
      review_kind: scan.review_kind,
      model: scan.model,
      prompt_version: scan.prompt_version,
      run_id: scan.run_id,
      review_status: scan.review_status,
      visibility: scan.visibility,
      verified: not is_nil(scan.verified_at),
      findings_count: scan.findings_count,
      details_visible: scan.details_visible,
      submitter: scan.submitted_by && scan.submitted_by.handle,
      findings: scan_findings_json(scan),
      confirmations: scan_confirmations_json(scan)
    }
  end

  defp scan_findings_json(%{details_visible: true, findings: findings}) when is_list(findings) do
    Enum.map(findings, fn finding ->
      %{
        public_id: finding.public_id,
        canonical_finding_id: finding.canonical_finding && finding.canonical_finding.public_id,
        disposition: finding.disposition,
        file: finding.file_path,
        line_start: finding.line_start,
        line_end: finding.line_end,
        severity: finding.severity,
        title: finding.title,
        description: finding.description
      }
    end)
  end

  defp scan_findings_json(_scan), do: []

  defp scan_confirmations_json(%{confirmations: confirmations}) when is_list(confirmations) do
    Enum.map(confirmations, fn confirmation ->
      %{
        verdict: confirmation.verdict,
        provenance: confirmation.provenance,
        verifier: confirmation.account && confirmation.account.handle
      }
    end)
  end

  defp scan_confirmations_json(_scan), do: []

  defp encode_document(%{"document" => document}) when is_map(document) do
    {:ok, Jason.encode!(document)}
  end

  defp encode_document(_params), do: :error

  defp scan_json(scan, repository) do
    base = TarakanWeb.Endpoint.url()
    findings = public_finding_links(scan, base)

    %{
      id: scan.id,
      kind: "report",
      repository: "#{repository.owner}/#{repository.name}",
      commit_sha: scan.commit_sha,
      commit_committed_at: scan.commit_committed_at,
      model: scan.model,
      prompt_version: scan.prompt_version,
      run_id: scan.run_id,
      provenance: scan.provenance,
      provenance_attestation: "self_reported",
      review_kind: scan.review_kind,
      findings_count: scan.findings_count,
      verified: Scans.Scan.verified?(scan),
      review_status: scan.review_status,
      visibility: scan.visibility,
      disclosed: scan.visibility in ["public", "public_summary"],
      record_url: base <> TarakanWeb.RepositoryPaths.repository_security_path(repository),
      findings: findings
    }
  end

  defp public_finding_links(%{findings: findings}, base) when is_list(findings) do
    Enum.map(findings, fn finding ->
      %{
        public_id: finding.public_id,
        title: finding.title,
        severity: finding.severity,
        url: base <> "/findings/#{finding.public_id}"
      }
    end)
  end

  defp public_finding_links(_scan, _base), do: []

  defp memory_finding_json(finding, target_commit_sha) do
    finding
    |> Map.put(:same_commit, finding.last_seen_commit_sha == target_commit_sha)
    |> Map.update!(:description, &String.slice(&1, 0, 1200))
  end

  @doc """
  Records a rubric-scored severity for one canonical finding.

  The submitter's original claim is never overwritten; this is stored beside it.
  """
  def finding_severity(
        conn,
        %{"host" => host_slug, "owner" => owner, "name" => name, "public_id" => public_id} =
          params
      ) do
    scope = conn.assigns.current_scope

    with %{} = repository <- visible_repository(host_slug, owner, name, scope),
         {:ok, finding} <- FindingMemory.get(repository, public_id),
         {:ok, updated} <-
           FindingSignals.calibrate_severity(
             scope,
             finding,
             params["severity"],
             params["rubric"]
           ) do
      json(conn, canonical_finding_json(updated))
    else
      nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"})
      {:error, :not_found} -> not_found_finding(conn)
      {:error, :unauthorized} -> forbidden(conn)
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset))
    end
  end

  @doc """
  Stores the worker's embedding of a finding's code context and clusters it.

  Vectors are only comparable within one model, so `embedding_model` is required
  and clustering never crosses models.
  """
  def finding_embedding(
        conn,
        %{"host" => host_slug, "owner" => owner, "name" => name, "public_id" => public_id} =
          params
      ) do
    scope = conn.assigns.current_scope

    with %{} = repository <- visible_repository(host_slug, owner, name, scope),
         {:ok, finding} <- FindingMemory.get(repository, public_id),
         {:ok, updated} <-
           FindingSignals.record_embedding(
             scope,
             finding,
             params["embedding"],
             params["embedding_model"]
           ) do
      json(conn, canonical_finding_json(updated))
    else
      nil -> conn |> put_status(:not_found) |> json(%{error: "repository is not registered"})
      {:error, :not_found} -> not_found_finding(conn)
      {:error, :unauthorized} -> forbidden(conn)
      {:error, %Ecto.Changeset{} = changeset} -> unprocessable(conn, changeset_errors(changeset))
    end
  end

  defp not_found_finding(conn) do
    conn |> put_status(:not_found) |> json(%{error: "canonical finding not found"})
  end

  defp forbidden(conn) do
    conn
    |> put_status(:forbidden)
    |> json(%{error: "not authorized to record signals on this finding"})
  end

  defp canonical_finding_json(finding) do
    %{
      public_id: finding.public_id,
      status: finding.status,
      commit_sha: finding.last_seen_commit_sha,
      detections_count: finding.detections_count,
      distinct_submitters_count: finding.distinct_submitters_count,
      distinct_models_count: finding.distinct_models_count,
      confirmations_count: finding.confirmations_count,
      disputes_count: finding.disputes_count,
      refutations_count: finding.refutations_count,
      survived_refutations_count: finding.survived_refutations_count,
      reproductions_count: finding.reproductions_count,
      reproduced_at: finding.reproduced_at,
      calibrated_severity: finding.calibrated_severity,
      code_pattern_key: finding.code_pattern_key,
      verified: finding.status == "verified"
    }
  end

  defp unprocessable(conn, errors) do
    conn
    |> put_status(:unprocessable_entity)
    |> json(%{errors: errors})
  end

  defp upstream_unavailable(conn, message) do
    conn
    |> put_status(:service_unavailable)
    |> json(%{error: message})
  end

  defp changeset_errors(changeset), do: TarakanWeb.ChangesetErrors.to_map(changeset)
end
