package smoke

import (
	"context"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"time"

	"github.com/chromedp/cdproto/runtime"
	"github.com/chromedp/chromedp"
)

var pathRe = regexp.MustCompile(`path:\s*['"]([^'"]+)['"]`)

// ParseRoutePaths returns static, smoke-testable route paths from a routes.tsx
// source: drops the '*' catch-all and any path with a ':' dynamic param.
// Always includes '/'. De-duplicated.
func ParseRoutePaths(routesSrc string) []string {
	seen := map[string]bool{"/": true}
	out := []string{"/"}
	for _, m := range pathRe.FindAllStringSubmatch(routesSrc, -1) {
		p := m[1]
		if p == "*" || p == "/" {
			continue
		}
		if containsColon(p) {
			continue
		}
		if !seen[p] {
			seen[p] = true
			out = append(out, p)
		}
	}
	return out
}

func containsColon(s string) bool {
	for i := 0; i < len(s); i++ {
		if s[i] == ':' {
			return true
		}
	}
	return false
}

// SmokeIssue is a single runtime-render problem found on a route.
type SmokeIssue struct {
	Route   string
	Problem string
}

var tagRe = regexp.MustCompile(`<[^>]*>`)

// stripTags removes HTML tags, leaving visible text separated by spaces.
func stripTags(html string) string {
	return tagRe.ReplaceAllString(html, " ")
}

// evaluateRender inspects a route's rendered #root HTML and console errors and
// returns a SmokeIssue if the page is blank, resolves to NotFound, or logged an
// uncaught error. Returns nil when the route looks healthy.
func evaluateRender(route, rootHTML string, consoleErrors []string) *SmokeIssue {
	trimmed := strings.TrimSpace(stripTags(rootHTML))
	if len(trimmed) < 5 {
		return &SmokeIssue{route, "page rendered blank (the app crashed or root is empty)"}
	}
	low := strings.ToLower(trimmed)
	if strings.Contains(low, "page not found") || strings.Contains(low, "404") {
		return &SmokeIssue{route, "route resolves to the NotFound page — it is not registered/working"}
	}
	if len(consoleErrors) > 0 {
		return &SmokeIssue{route, "uncaught console error: " + consoleErrors[0]}
	}
	return nil
}

// smokeBase is the non-empty URL prefix the smoke gate serves the app under.
// The project templates set the React Router basename from the <base href>,
// falling back to "/preview" when it is absent or "/". Serving the raw dist at
// the server root would therefore leave the router with basename="/preview"
// while the URL is "/", matching no route and rendering blank (no error). So we
// serve under a real prefix AND inject a matching <base href> so routes resolve.
const smokeBase = "/smoke"

// Smoke serves distDir over HTTP (under smokeBase, with SPA index.html fallback)
// and loads each route in a headless browser, checking that #root renders without
// errors. The passed base is ignored — the gate forces its own non-empty base so
// the template router basename matches (see smokeBase). routes are paths to visit.
// appConfigJSON, when non-empty, is injected into the served index.html as
// `window.__APP_CONFIG__` so the app runs with the SAME runtime config the preview
// serves it with (e.g. Supabase url/key). If chromedp cannot start, Smoke returns
// (nil, err) so callers can skip the gate.
func Smoke(ctx context.Context, distDir, base string, routes []string, appConfigJSON string) ([]SmokeIssue, error) {
	_ = base // the gate serves under smokeBase; the raw dist's base href ("/") is unusable.
	server := httptest.NewServer(spaHandler(distDir, appConfigJSON))
	defer server.Close()

	// Headless allocator — mirror internal/browser/session.go options.
	opts := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.Headless,
		chromedp.DisableGPU,
		chromedp.NoSandbox,
		chromedp.Flag("disable-dev-shm-usage", true),
	)
	allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
	defer allocCancel()

	browserCtx, browserCancel := chromedp.NewContext(allocCtx)
	defer browserCancel()

	// Force-start Chrome so an unavailable browser surfaces as an error and the
	// caller can skip the smoke gate rather than fail the build.
	if err := chromedp.Run(browserCtx); err != nil {
		return nil, fmt.Errorf("chrome unavailable: %w", err)
	}

	var issues []SmokeIssue
	for _, route := range routes {
		iss, err := smokeRoute(browserCtx, server.URL+smokeBase+route, route)
		if err != nil {
			// Treat a navigation/render failure as a render issue rather than a
			// hard error so one bad route doesn't abort the whole gate.
			issues = append(issues, SmokeIssue{route, "failed to load: " + err.Error()})
			continue
		}
		if iss != nil {
			issues = append(issues, *iss)
		}
	}
	return issues, nil
}

// smokeRoute navigates to a single URL, captures console errors, reads the
// #root innerHTML, and evaluates the render. It uses a per-route timeout.
func smokeRoute(parent context.Context, url, route string) (*SmokeIssue, error) {
	ctx, cancel := context.WithTimeout(parent, 15*time.Second)
	defer cancel()

	tabCtx, tabCancel := chromedp.NewContext(ctx)
	defer tabCancel()

	var mu struct {
		errs []string
	}
	chromedp.ListenTarget(tabCtx, func(ev interface{}) {
		switch e := ev.(type) {
		case *runtime.EventConsoleAPICalled:
			if e.Type == runtime.APITypeError {
				mu.errs = append(mu.errs, consoleArgsToString(e.Args))
			}
		case *runtime.EventExceptionThrown:
			if e.ExceptionDetails != nil {
				mu.errs = append(mu.errs, e.ExceptionDetails.Text)
			}
		}
	})

	var rootHTML string
	err := chromedp.Run(tabCtx,
		chromedp.Navigate(url),
		chromedp.Sleep(2*time.Second),
		chromedp.Evaluate(`(function(){var r=document.querySelector('#root');return r?r.innerHTML:''})()`, &rootHTML),
	)
	if err != nil {
		return nil, err
	}
	return evaluateRender(route, rootHTML, mu.errs), nil
}

// consoleArgsToString renders console.error arguments into a single string.
func consoleArgsToString(args []*runtime.RemoteObject) string {
	parts := make([]string, 0, len(args))
	for _, a := range args {
		if a == nil {
			continue
		}
		if a.Value != nil {
			parts = append(parts, strings.Trim(string(a.Value), `"`))
		} else if a.Description != "" {
			parts = append(parts, a.Description)
		}
	}
	return strings.Join(parts, " ")
}

// spaHandler serves distDir under smokeBase, falling back to index.html for
// unknown paths so client-side routes resolve (single-page-app behaviour). Every
// index.html response gets a <base href="smokeBase/"> (so the template router
// basename matches the served path) and, when non-empty, window.__APP_CONFIG__
// (so config-dependent apps boot with their real runtime config). Both mirror how
// the live preview is served; without them config/router-dependent apps render
// blank and produce false "all pages blank" failures.
func spaHandler(distDir, appConfigJSON string) http.Handler {
	indexPath := filepath.Join(distDir, "index.html")
	serveIndex := func(w http.ResponseWriter, r *http.Request) {
		raw, err := os.ReadFile(indexPath)
		if err != nil {
			http.NotFound(w, r)
			return
		}
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		_, _ = w.Write([]byte(prepareIndexHTML(string(raw), smokeBase+"/", appConfigJSON)))
	}
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Requests arrive under smokeBase ("/smoke/..."); strip it to resolve files.
		rel := strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, smokeBase), "/")
		if rel == "" || rel == "index.html" {
			serveIndex(w, r)
			return
		}
		// filepath.Clean("/"+rel) collapses any ".." so a request cannot escape distDir.
		full := filepath.Join(distDir, filepath.Clean("/"+rel))
		if fi, err := os.Stat(full); err == nil && !fi.IsDir() {
			http.ServeFile(w, r, full)
			return
		}
		// Unknown path -> serve index.html (with base+config) for SPA routing.
		serveIndex(w, r)
	})
}

// prepareIndexHTML injects the smoke <base href> and the runtime config into a
// served index.html so the app boots exactly as it would behind the live preview.
func prepareIndexHTML(html, baseHref, configJSON string) string {
	return injectAppConfig(injectBaseHref(html, baseHref), configJSON)
}

// injectBaseHref inserts `<base href="...">` immediately after the opening <head>
// tag so it precedes relative asset URLs (which resolve against it). Falls back to
// prepending when there is no <head>.
func injectBaseHref(html, href string) string {
	tag := `<base href="` + href + `">`
	if idx := headOpenEnd(html); idx >= 0 {
		return html[:idx] + tag + html[idx:]
	}
	return tag + html
}

// headOpenEnd returns the index just past the opening <head ...> tag, or -1.
func headOpenEnd(html string) int {
	lower := strings.ToLower(html)
	i := strings.Index(lower, "<head")
	if i < 0 {
		return -1
	}
	j := strings.Index(lower[i:], ">")
	if j < 0 {
		return -1
	}
	return i + j + 1
}

// injectAppConfig inserts `<script>window.__APP_CONFIG__ = <json>;</script>`
// just before </head> so it runs before the app bundle. A no-op when configJSON
// is empty. Falls back to prepending if there is no <head>.
func injectAppConfig(html, configJSON string) string {
	if configJSON == "" {
		return html
	}
	script := "<script>window.__APP_CONFIG__ = " + configJSON + ";</script>"
	if idx := strings.Index(html, "</head>"); idx >= 0 {
		return html[:idx] + script + html[idx:]
	}
	return script + html
}
