defmodule Mix.Tasks.Lighthouse do
  @shortdoc "Run Lighthouse PageSpeed audit against the shop"
  @moduledoc """
  Runs Google Lighthouse against one or more pages and checks scores
  against configurable thresholds.

  Starts the Phoenix server on port 4002 (so it won't clash with a
  running dev server), runs each audit, then prints a colour-coded
  pass/fail report. Exits with code 1 if any score falls below its
  threshold.

  ## Prerequisites

  - `lighthouse` CLI in PATH (`npm install -g lighthouse`)
  - Chrome or Chromium installed

  ## Usage

      # Audit the home page with default thresholds (90 for all categories)
      mix lighthouse

      # Audit specific pages
      mix lighthouse / /about /products/1

      # Override thresholds
      mix lighthouse --performance 95 --accessibility 100

      # Desktop mode (default is mobile)
      mix lighthouse --strategy desktop

  ## Options

  * `--performance N` - Minimum performance score (default: 90)
  * `--accessibility N` - Minimum accessibility score (default: 90)
  * `--best-practices N` - Minimum best practices score (default: 90)
  * `--seo N` - Minimum SEO score (default: 90)
  * `--strategy MODE` - "mobile" or "desktop" (default: "mobile")
  * `--port PORT` - Port to start the server on (default: 4002)
  """

  use Mix.Task

  import Ecto.Query

  @default_port 4002
  @default_strategy "mobile"
  @categories ["performance", "accessibility", "best-practices", "seo"]

  @default_thresholds %{
    "performance" => 90,
    "accessibility" => 90,
    "best-practices" => 90,
    "seo" => 90
  }

  @category_labels %{
    "performance" => "Performance",
    "accessibility" => "Accessibility",
    "best-practices" => "Best practices",
    "seo" => "SEO"
  }

  @impl Mix.Task
  def run(args) do
    {opts, pages, _} =
      OptionParser.parse(args,
        strict: [
          performance: :integer,
          accessibility: :integer,
          best_practices: :integer,
          seo: :integer,
          strategy: :string,
          port: :integer
        ]
      )

    pages = if pages == [], do: ["/"], else: pages
    port = opts[:port] || @default_port
    strategy = opts[:strategy] || @default_strategy

    unless strategy in ["mobile", "desktop"] do
      Mix.raise(~s(--strategy must be "mobile" or "desktop", got: #{inspect(strategy)}))
    end

    thresholds =
      @default_thresholds
      |> maybe_put("performance", opts[:performance])
      |> maybe_put("accessibility", opts[:accessibility])
      |> maybe_put("best-practices", opts[:best_practices])
      |> maybe_put("seo", opts[:seo])

    lighthouse_path = check_lighthouse!()
    build_prod_assets!()
    base_url = start_server!(port)
    wait_for_images()

    Mix.shell().info("Running Lighthouse (#{strategy}) against #{length(pages)} page(s)...\n")

    results =
      Enum.map(pages, fn path ->
        Mix.shell().info("  Auditing #{path}...")
        run_audit(lighthouse_path, base_url, path, strategy)
      end)

    all_passed = print_results(results, thresholds)

    unless all_passed do
      exit({:shutdown, 1})
    end
  end

  # -- Prerequisites --

  defp check_lighthouse! do
    case System.find_executable("lighthouse") do
      nil ->
        Mix.raise("""
        lighthouse CLI not found in PATH.

        Install it with:
            npm install -g lighthouse

        You'll also need Chrome or Chromium installed.
        """)

      path ->
        path
    end
  end

  # -- Asset build --

  defp build_prod_assets! do
    Mix.shell().info("Building production assets...")

    Mix.Task.run("esbuild", ["berrypod", "--minify"])
    Mix.Task.run("esbuild", ["berrypod_shop_css", "--minify"])
    Mix.Task.run("esbuild", ["berrypod_admin_css", "--minify"])
    Mix.Task.run("phx.digest")

    Mix.shell().info("  Assets built and digested.")
  end

  # -- Server lifecycle --

  defp start_server!(port) do
    config = Application.get_env(:berrypod, BerrypodWeb.Endpoint, [])

    config =
      Keyword.merge(config,
        http: [ip: {127, 0, 0, 1}, port: port],
        server: true,
        watchers: [],
        cache_static_manifest: "priv/static/cache_manifest.json"
      )

    Application.put_env(:berrypod, BerrypodWeb.Endpoint, config)

    Mix.Task.run("app.start")

    base_url = "http://localhost:#{port}"
    wait_for_server(base_url, 20)
    base_url
  end

  defp wait_for_server(_url, 0) do
    Mix.raise("Server failed to start — timed out waiting for it to accept connections")
  end

  defp wait_for_server(url, attempts) do
    Application.ensure_all_started(:inets)
    Application.ensure_all_started(:ssl)

    case :httpc.request(:get, {~c"#{url}/health", []}, [timeout: 2_000], []) do
      {:ok, {{_, status, _}, _, _}} when status in 200..399 ->
        :ok

      _ ->
        Process.sleep(250)
        wait_for_server(url, attempts - 1)
    end
  end

  # Wait for the image variant cache to finish processing so background
  # IO doesn't skew lighthouse results
  defp wait_for_images do
    pending = count_pending_images()

    if pending > 0 do
      Mix.shell().info("Waiting for #{pending} image variant(s) to finish processing...")
      do_wait_for_images(180)
    end
  end

  defp do_wait_for_images(0) do
    Mix.shell().info(
      "  #{IO.ANSI.yellow()}Timed out waiting for image variants — running audits anyway#{IO.ANSI.reset()}"
    )
  end

  defp do_wait_for_images(seconds_left) do
    case count_pending_images() do
      0 ->
        Mix.shell().info("  Image variants ready.")

      n ->
        if rem(seconds_left, 10) == 0, do: Mix.shell().info("  #{n} image(s) still processing...")
        Process.sleep(1_000)
        do_wait_for_images(seconds_left - 1)
    end
  end

  defp count_pending_images do
    Berrypod.Media.Image
    |> where([i], i.is_svg == false)
    |> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
    |> Berrypod.Repo.aggregate(:count)
  end

  # -- Lighthouse execution --

  defp run_audit(lighthouse_path, base_url, path, strategy) do
    url = base_url <> path

    report_path =
      Path.join(System.tmp_dir!(), "lh-#{System.unique_integer([:positive])}.json")

    try do
      args =
        [
          url,
          "--output=json",
          "--output-path=#{report_path}",
          "--chrome-flags=--headless --no-sandbox",
          "--only-categories=#{Enum.join(@categories, ",")}",
          "--quiet"
        ]
        |> maybe_append(strategy == "desktop", "--preset=desktop")

      case System.cmd(lighthouse_path, args, stderr_to_stdout: true) do
        {_output, 0} ->
          parse_report(report_path, path)

        {output, _} ->
          {:error, path, interpret_error(output)}
      end
    after
      File.rm(report_path)
    end
  end

  defp parse_report(report_path, path) do
    with {:ok, json} <- File.read(report_path),
         {:ok, %{"categories" => categories}} <- Jason.decode(json) do
      scores =
        Map.new(categories, fn {key, %{"score" => score}} ->
          {key, if(is_number(score), do: round(score * 100), else: nil)}
        end)

      {:ok, path, scores}
    else
      {:ok, _} -> {:error, path, "unexpected JSON structure in Lighthouse report"}
      {:error, reason} -> {:error, path, "failed to read report: #{inspect(reason)}"}
    end
  end

  defp interpret_error(output) do
    cond do
      output =~ "Chrome" or output =~ "chromium" ->
        """
        Chrome/Chromium not found. Lighthouse needs a browser to run.

        Install Chrome or Chromium, or set CHROME_PATH:
            export CHROME_PATH=/usr/bin/chromium-browser
        """

      output =~ "ECONNREFUSED" ->
        "couldn't connect to the server — it may have failed to start"

      true ->
        "lighthouse error:\n#{String.slice(output, 0, 500)}"
    end
  end

  # -- Output --

  defp print_results(results, thresholds) do
    Mix.shell().info("")
    Mix.shell().info("#{IO.ANSI.bright()}Lighthouse results#{IO.ANSI.reset()}")
    Mix.shell().info(String.duplicate("─", 56))

    all_passed =
      Enum.reduce(results, true, fn result, all_ok ->
        case result do
          {:ok, path, scores} ->
            Mix.shell().info("\n  #{IO.ANSI.bright()}#{path}#{IO.ANSI.reset()}")
            page_ok = print_scores(scores, thresholds)
            all_ok and page_ok

          {:error, path, message} ->
            Mix.shell().info("\n  #{IO.ANSI.bright()}#{path}#{IO.ANSI.reset()}")
            Mix.shell().info("    #{IO.ANSI.red()}ERROR#{IO.ANSI.reset()} #{message}")
            false
        end
      end)

    Mix.shell().info("")
    Mix.shell().info(String.duplicate("─", 56))

    if all_passed do
      Mix.shell().info("#{IO.ANSI.green()}All audits passed.#{IO.ANSI.reset()}\n")
    else
      Mix.shell().info("#{IO.ANSI.red()}Some audits failed.#{IO.ANSI.reset()}\n")
    end

    all_passed
  end

  defp print_scores(scores, thresholds) do
    Enum.reduce(@categories, true, fn cat, all_ok ->
      score = Map.get(scores, cat)
      threshold = Map.get(thresholds, cat, 0)
      label = Map.get(@category_labels, cat, cat)

      if is_nil(score) do
        Mix.shell().info(
          "    #{IO.ANSI.red()}FAIL#{IO.ANSI.reset()}  #{String.pad_trailing(label, 16)} #{IO.ANSI.red()}N/A#{IO.ANSI.reset()} / #{threshold}"
        )

        false
      else
        passed = score >= threshold

        icon =
          if passed,
            do: "#{IO.ANSI.green()}pass#{IO.ANSI.reset()}",
            else: "#{IO.ANSI.red()}FAIL#{IO.ANSI.reset()}"

        Mix.shell().info(
          "    #{icon}  #{String.pad_trailing(label, 16)} #{score_colour(score)}#{score}#{IO.ANSI.reset()} / #{threshold}"
        )

        all_ok and passed
      end
    end)
  end

  defp score_colour(score) when score >= 90, do: IO.ANSI.green()
  defp score_colour(score) when score >= 50, do: IO.ANSI.yellow()
  defp score_colour(_score), do: IO.ANSI.red()

  # -- Helpers --

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, val), do: Map.put(map, key, val)

  defp maybe_append(list, true, item), do: list ++ [item]
  defp maybe_append(list, false, _item), do: list
end
