defmodule Mix.Tasks.Screenshots do
  @shortdoc "Capture visual regression screenshots of all shop pages"
  @moduledoc """
  Takes screenshots of shop pages at multiple breakpoints for visual
  regression testing during the CSS migration.

  Uses Playwright for reliable viewport sizing and page load handling.

  ## Prerequisites

  - Node.js in PATH
  - Playwright installed (`npx playwright install chromium`)

  ## Usage

      # Capture screenshots (saved to screenshots/)
      mix screenshots

      # Capture only specific pages
      mix screenshots /about /cart

      # Save to a named directory (e.g. "before" and "after")
      mix screenshots --name before
      mix screenshots --name after

      # Override port
      mix screenshots --port 4003

  ## Output

  Screenshots are saved to `screenshots/<name>/` with filenames
  like `home-375.png`, `collection-768.png`, etc.

  Default name is a timestamp (YYYYMMDD-HHMMSS).
  """

  use Mix.Task

  import Ecto.Query

  @default_port 4003

  @breakpoints [
    {"375", 375},
    {"768", 768},
    {"1024", 1024},
    {"1440", 1440}
  ]

  @pages [
    {"home", "/", nil},
    {"collection", "/collections/all", nil},
    {"about", "/about", nil},
    {"contact", "/contact", nil},
    {"delivery", "/delivery", nil},
    {"privacy", "/privacy", nil},
    {"terms", "/terms", nil},
    {"cart-empty", "/cart", nil},
    {"error-404", "/nonexistent-page-404", nil}
  ]

  @impl Mix.Task
  def run(args) do
    {opts, filter_paths, _} =
      OptionParser.parse(args,
        strict: [name: :string, port: :integer]
      )

    port = opts[:port] || @default_port
    name = opts[:name] || timestamp()
    output_dir = Path.join("screenshots", name)

    pw_path = find_playwright!()
    build_prod_assets!()
    base_url = start_server!(port)
    wait_for_images()

    File.mkdir_p!(output_dir)

    pages = resolve_pages(base_url, filter_paths)
    total = length(pages) * length(@breakpoints)

    Mix.shell().info(
      "Capturing #{total} screenshots (#{length(pages)} pages x #{length(@breakpoints)} breakpoints)..."
    )

    Mix.shell().info("Output: #{output_dir}/\n")

    script = build_playwright_script(pages, base_url, output_dir)

    script_path =
      Path.join(System.tmp_dir!(), "screenshots-#{System.unique_integer([:positive])}.cjs")

    try do
      File.write!(script_path, script)

      # Set NODE_PATH so require("playwright") resolves from the npx cache
      case System.cmd("node", [script_path],
             stderr_to_stdout: true,
             env: [{"NODE_PATH", pw_path}]
           ) do
        {output, 0} ->
          Mix.shell().info(output)
          count = count_screenshots(output_dir)

          Mix.shell().info(
            "\n#{IO.ANSI.green()}Done. #{count} screenshots saved to #{output_dir}/#{IO.ANSI.reset()}"
          )

        {output, code} ->
          Mix.shell().error("Screenshot capture failed (exit #{code}):\n#{output}")
          exit({:shutdown, 1})
      end
    after
      File.rm(script_path)
    end
  end

  # Locate the playwright package directory from the npx cache
  defp find_playwright! do
    {output, 0} =
      System.cmd("npx", ["playwright", "--version"], stderr_to_stdout: true)

    unless String.contains?(output, ".") do
      Mix.raise("""
      Playwright not found. Install it with:

          npx playwright install chromium
      """)
    end

    # Find the actual node_modules path containing playwright
    {cache_output, _} =
      System.cmd("find", [
        Path.expand("~/.npm/_npx"),
        "-name",
        "playwright",
        "-type",
        "d",
        "-path",
        "*/node_modules/playwright"
      ])

    case cache_output |> String.trim() |> String.split("\n") |> List.first() do
      nil ->
        Mix.raise("Found playwright via npx but can't locate the package directory")

      "" ->
        Mix.raise("Found playwright via npx but can't locate the package directory")

      path ->
        Mix.shell().info("  Using playwright from #{path}")
        # Return the node_modules directory (parent of playwright/)
        Path.dirname(path)
    end
  end

  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

  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")
  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

  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(120)
    end
  end

  defp do_wait_for_images(0), do: Mix.shell().info("  Timed out — running screenshots anyway")

  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

  defp resolve_pages(base_url, []) do
    dynamic = dynamic_pages(base_url)
    @pages ++ dynamic
  end

  defp resolve_pages(_base_url, filter_paths) do
    Enum.filter(@pages, fn {_slug, path, _} -> path in filter_paths end)
  end

  defp dynamic_pages(_base_url) do
    case Berrypod.Products.list_visible_products(limit: 1) do
      [product] ->
        [{"pdp", "/products/#{product.id}", nil}]

      _ ->
        Mix.shell().info("  No products found — skipping PDP screenshot")
        []
    end
  end

  defp build_playwright_script(pages, base_url, output_dir) do
    breakpoint_entries =
      Enum.map_join(@breakpoints, ",\n    ", fn {label, width} ->
        ~s({ label: "#{label}", width: #{width} })
      end)

    page_entries =
      Enum.map_join(pages, ",\n    ", fn {slug, path, _setup} ->
        ~s({ slug: "#{slug}", path: "#{path}" })
      end)

    ~s"""
    const { chromium } = require("playwright");
    const path = require("path");

    const baseUrl = "#{base_url}";
    const outputDir = "#{output_dir}";

    const breakpoints = [
        #{breakpoint_entries}
    ];

    const pages = [
        #{page_entries}
    ];

    (async () => {
      const browser = await chromium.launch();

      for (const pg of pages) {
        for (const bp of breakpoints) {
          const context = await browser.newContext({
            viewport: { width: bp.width, height: 900 },
            deviceScaleFactor: 1
          });
          const tab = await context.newPage();

          try {
            await tab.goto(baseUrl + pg.path, { waitUntil: "networkidle", timeout: 15000 });
            await tab.waitForSelector("[data-phx-main]", { timeout: 5000 }).catch(() => {});
            await tab.waitForTimeout(500);

            const filename = pg.slug + "-" + bp.label + ".png";
            await tab.screenshot({
              path: path.join(outputDir, filename),
              fullPage: true
            });
            console.log("  " + filename);
          } catch (err) {
            console.error("  FAILED: " + pg.slug + "-" + bp.label + ": " + err.message);
          }

          await context.close();
        }
      }

      await browser.close();
      console.log("\\nScreenshots complete.");
    })();
    """
  end

  defp count_screenshots(dir) do
    dir
    |> File.ls!()
    |> Enum.count(&String.ends_with?(&1, ".png"))
  end

  defp timestamp do
    {{y, m, d}, {h, min, s}} = :calendar.local_time()

    :io_lib.format("~4..0B~2..0B~2..0B-~2..0B~2..0B~2..0B", [y, m, d, h, min, s])
    |> IO.iodata_to_binary()
  end
end
