package engine

import (
	"fmt"
	"net/url"
	"time"

	foxhound "github.com/sadewadee/foxhound"
)

// StepAction identifies what a Trail Step should do.
type StepAction int

const (
	// StepNavigate navigates to a URL (creates a Job).
	StepNavigate StepAction = iota
	// StepClick clicks a CSS selector (browser-mode only).
	StepClick
	// StepWait waits for a CSS selector to appear or a fixed duration.
	StepWait
	// StepExtract runs a Processor against the current page.
	StepExtract
	// StepScroll scrolls the page (browser-mode only).
	StepScroll
	// StepInfiniteScroll scrolls to bottom repeatedly until no new content
	// loads (for lazy-load / infinite scroll pages like Google Maps).
	StepInfiniteScroll
	// StepLoadMore clicks a "load more" button repeatedly until it
	// disappears or max clicks reached.
	StepLoadMore
	// StepPaginate detects pagination links ("Next", page numbers) and
	// follows them, collecting content from each page.
	StepPaginate
	// StepEvaluate executes custom JavaScript on the page.
	StepEvaluate
	// StepFill types text into an input field with human-like keystrokes.
	StepFill
	// StepCollect extracts URLs from matching elements into a Pool.
	StepCollect
)

// Step is a single action within a Trail.
type Step struct {
	// Action is the kind of step.
	Action StepAction
	// URL is the target for StepNavigate.
	URL string
	// Selector is the CSS selector for StepClick, StepWait, and StepExtract.
	// For InfiniteScroll, Selector is the scrollable container (empty = whole page).
	Selector string
	// Duration is the timeout/wait for StepWait.
	Duration time.Duration
	// Process is the extraction logic for StepExtract.
	Process foxhound.Processor
	// MaxScrolls limits InfiniteScroll iterations.
	MaxScrolls int
	// MaxClicks limits LoadMore button clicks.
	MaxClicks int
	// MaxPages limits Paginate page follows.
	MaxPages int
	// Script is the JavaScript code for StepEvaluate.
	Script string
	// StopSelector is a CSS selector; InfiniteScroll stops when
	// document.querySelectorAll(StopSelector).length >= StopCount.
	StopSelector string
	// StopCount is the target element count for StopSelector.
	StopCount int
	// ScrollWait is the duration to wait after each scroll iteration before
	// checking for new content. Defaults to 2s when zero.
	ScrollWait time.Duration
	// Optional marks this step as non-fatal: if it fails, execution continues.
	Optional bool
	// Value is the text to type into an input field for StepFill.
	Value string
}

// Trail is a reusable navigation blueprint composed of ordered Steps.
// It is built via a fluent builder API and converted to Jobs when submitted to
// a Hunt.
type Trail struct {
	// Name is a human-readable label for this navigation path.
	Name string
	// Steps is the ordered sequence of actions.
	Steps      []Step
	skipWarmup bool
	// adaptive holds deferred adaptive selector registrations: each pair
	// is (name, css selector). Applied by the walker against the Hunt's
	// shared AdaptiveExtractor when the resulting job is fetched.
	adaptive [][2]string
	// captureXHR holds URL regexp patterns to capture XHR/fetch responses
	// for. Applied to every job produced by ToJobs via Job.Meta and consumed
	// by the camoufox fetcher to populate Response.CapturedXHR.
	captureXHR []string
}

// CaptureXHR registers a URL regexp pattern. While any job produced by this
// Trail is fetched, the browser fetcher captures every XHR or fetch response
// whose URL matches the pattern, storing the request URL, status, headers,
// and body in Response.CapturedXHR. Use this to discover the JSON API behind
// a JavaScript-rendered page without parsing the DOM.
//
// Multiple calls accumulate; all patterns are matched (logical OR).
// Patterns must be valid Go regexps; invalid patterns are silently dropped
// at fetch time.
func (t *Trail) CaptureXHR(urlPattern string) *Trail {
	t.captureXHR = append(t.captureXHR, urlPattern)
	return t
}

// Adaptive registers an adaptive selector that survives DOM rewrites by
// falling back to similarity matching when the primary CSS selector fails
// to match on a future run. The element signature is learned automatically
// on the first successful extraction and persisted via the Hunt's adaptive
// store (configured by Hunt.WithAdaptive).
//
// Adaptive only records the registration intent on the Trail; the actual
// Register and signature learning happens when the produced Job is fetched
// by a walker, so the Hunt must have been configured with WithAdaptive
// before Run is called.
func (t *Trail) Adaptive(name, selector string) *Trail {
	t.adaptive = append(t.adaptive, [2]string{name, selector})
	return t
}

// defaultWarmupWait is the duration the warm-up visit waits for "body" to
// appear before proceeding to the target URL.
const defaultWarmupWait = 2 * time.Second

// NewTrail creates a new empty Trail with the given name.
func NewTrail(name string) *Trail {
	return &Trail{Name: name}
}

// Login builds a login trail that navigates to the login page, fills
// credentials, and submits the form. The returned trail can be further
// chained with additional steps (e.g. WaitOptional for a post-login element).
func Login(name, loginURL, userSelector, passSelector, submitSelector, username, password string) *Trail {
	return NewTrail(name).
		Navigate(loginURL).
		WaitOptional("body", 5*time.Second).
		Fill(userSelector, username).
		Fill(passSelector, password).
		Click(submitSelector).
		WaitOptional("body", 10*time.Second)
}

// NoWarmup disables the automatic homepage warm-up visit that ToJobs()
// prepends by default. Use this when speed is more important than stealth,
// or when the trail already starts at the homepage.
func (t *Trail) NoWarmup() *Trail {
	t.skipWarmup = true
	return t
}

// homepageURL returns the scheme+host root URL for rawURL, or "" on error.
func homepageURL(rawURL string) string {
	u, err := url.Parse(rawURL)
	if err != nil || u.Host == "" {
		return ""
	}
	return u.Scheme + "://" + u.Host + "/"
}

// isHomepage reports whether rawURL points to the root of its origin
// (path is empty or "/").
func isHomepage(rawURL string) bool {
	u, err := url.Parse(rawURL)
	if err != nil {
		return false
	}
	return u.Path == "" || u.Path == "/"
}

// hasBrowserSteps reports whether the trail contains at least one step that
// requires a browser (i.e., anything other than Navigate and Extract).
func (t *Trail) hasBrowserSteps() bool {
	for _, s := range t.Steps {
		switch s.Action {
		case StepClick, StepWait, StepScroll, StepInfiniteScroll,
			StepLoadMore, StepPaginate, StepEvaluate, StepFill, StepCollect:
			return true
		}
	}
	return false
}

// firstNavigateURL returns the URL of the first StepNavigate in the trail,
// or "" if there is none.
func (t *Trail) firstNavigateURL() string {
	for _, s := range t.Steps {
		if s.Action == StepNavigate {
			return s.URL
		}
	}
	return ""
}

// Navigate appends a StepNavigate step that fetches url.
func (t *Trail) Navigate(url string) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepNavigate, URL: url})
	return t
}

// Click appends a StepClick step that clicks the element matching selector.
// This step is only meaningful when using the browser fetcher.
func (t *Trail) Click(selector string) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepClick, Selector: selector})
	return t
}

// Wait appends a StepWait step that blocks until selector appears or timeout
// elapses.
func (t *Trail) Wait(selector string, timeout time.Duration) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepWait, Selector: selector, Duration: timeout})
	return t
}

// Extract appends a StepExtract step that runs processor against the current
// page response.
func (t *Trail) Extract(processor foxhound.Processor) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepExtract, Process: processor})
	return t
}

// Scroll appends a StepScroll step that scrolls the page. This step is only
// meaningful when using the browser fetcher.
func (t *Trail) Scroll() *Trail {
	t.Steps = append(t.Steps, Step{Action: StepScroll})
	return t
}

// InfiniteScroll appends a step that scrolls to the bottom repeatedly until
// no new content loads (for lazy-load / infinite scroll pages). maxScrolls
// limits iterations (0 = default 50). Scrolls the whole page.
func (t *Trail) InfiniteScroll(maxScrolls int) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepInfiniteScroll, MaxScrolls: maxScrolls})
	return t
}

// InfiniteScrollWithWait appends an InfiniteScroll with custom post-scroll wait.
func (t *Trail) InfiniteScrollWithWait(maxScrolls int, scrollWait time.Duration) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepInfiniteScroll, MaxScrolls: maxScrolls, ScrollWait: scrollWait})
	return t
}

// InfiniteScrollIn appends an InfiniteScroll step that scrolls inside a
// specific container element (e.g. Google Maps results panel, Facebook feed).
// container is a CSS selector for the scrollable element.
func (t *Trail) InfiniteScrollIn(container string, maxScrolls int) *Trail {
	t.Steps = append(t.Steps, Step{
		Action:     StepInfiniteScroll,
		Selector:   container,
		MaxScrolls: maxScrolls,
	})
	return t
}

// InfiniteScrollUntil appends an InfiniteScroll step that stops when
// stopSelector matches at least stopCount elements. This scrolls until
// the target is reached rather than until content stops loading.
func (t *Trail) InfiniteScrollUntil(stopSelector string, stopCount int, maxScrolls int) *Trail {
	t.Steps = append(t.Steps, Step{
		Action:       StepInfiniteScroll,
		MaxScrolls:   maxScrolls,
		StopSelector: stopSelector,
		StopCount:    stopCount,
	})
	return t
}

// InfiniteScrollInUntil combines container scrolling with a stop condition.
func (t *Trail) InfiniteScrollInUntil(container, stopSelector string, stopCount, maxScrolls int) *Trail {
	t.Steps = append(t.Steps, Step{
		Action:       StepInfiniteScroll,
		Selector:     container,
		MaxScrolls:   maxScrolls,
		StopSelector: stopSelector,
		StopCount:    stopCount,
	})
	return t
}

// Evaluate appends a step that executes custom JavaScript on the page.
// The return value of the script is available in Response.StepResults.
func (t *Trail) Evaluate(script string) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepEvaluate, Script: script})
	return t
}

// LoadMore appends a step that clicks the element matching selector repeatedly
// until it disappears or maxClicks is reached (0 = default 20).
func (t *Trail) LoadMore(selector string, maxClicks int) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepLoadMore, Selector: selector, MaxClicks: maxClicks})
	return t
}

// Paginate appends a step that detects pagination links matching selector
// (e.g. "a.next", "li.next a") and follows them, collecting content from each
// page. maxPages limits how many pages to follow (0 = default 10).
func (t *Trail) Paginate(selector string, maxPages int) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepPaginate, Selector: selector, MaxPages: maxPages})
	return t
}

// ClickOptional appends a StepClick step that does NOT abort the fetch on
// failure. Useful for dismissing elements that may or may not be present.
func (t *Trail) ClickOptional(selector string) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepClick, Selector: selector, Optional: true})
	return t
}

// WaitOptional appends a StepWait step that does NOT abort the fetch on
// failure. Useful for waiting on elements that may not appear on every page.
func (t *Trail) WaitOptional(selector string, timeout time.Duration) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepWait, Selector: selector, Duration: timeout, Optional: true})
	return t
}

// Fill appends a StepFill step that types value into the input matching
// selector with human-like keystrokes (using behavior.Keyboard).
func (t *Trail) Fill(selector, value string) *Trail {
	t.Steps = append(t.Steps, Step{Action: StepFill, Selector: selector, Value: value})
	return t
}

// Collect appends a step that extracts URLs from all elements matching
// selector, reading the given attribute (typically "href"). The collected
// URLs are stored in Response.StepResults as []string.
//
// This step is implemented as a JS Evaluate that runs
// querySelectorAll(selector) and returns the attribute values.
func (t *Trail) Collect(selector, attr string) *Trail {
	t.Steps = append(t.Steps, Step{
		Action:   StepCollect,
		Selector: selector,
		Value:    attr,
	})
	return t
}

// ToJobs converts the Trail into foxhound.Jobs. Each StepNavigate starts a
// new Job; subsequent browser steps (Click, Wait, Scroll) are attached as
// JobSteps on that Job and set FetchMode to FetchBrowser.
//
// Extract steps are NOT converted to JobSteps because their Processor
// (an interface) cannot survive JSON serialization through queue backends.
// Extraction is handled by the hunt-level Processor after the fetch completes.
//
// Steps that appear before the first Navigate are silently skipped.
//
// By default, when the trail has browser steps and the first Navigate URL is
// not the site homepage, ToJobs prepends a warm-up Job that visits the
// homepage first to seed cookies and build a natural referrer chain. Call
// NoWarmup() to disable this behaviour.
func (t *Trail) ToJobs() []*foxhound.Job {
	steps := t.Steps

	// Warm-up: prepend homepage visit for browser-mode trails.
	if !t.skipWarmup && t.hasBrowserSteps() {
		firstURL := t.firstNavigateURL()
		if firstURL != "" && !isHomepage(firstURL) {
			hp := homepageURL(firstURL)
			if hp != "" {
				warmupSteps := []Step{
					{Action: StepNavigate, URL: hp},
					{Action: StepWait, Selector: "body", Duration: defaultWarmupWait, Optional: true},
				}
				steps = append(warmupSteps, steps...)
			}
		}
	}

	var jobs []*foxhound.Job
	var current *foxhound.Job

	for _, step := range steps {
		if step.Action == StepNavigate {
			// Start a new segment.
			current = &foxhound.Job{
				ID:        step.URL,
				URL:       step.URL,
				Method:    "GET",
				CreatedAt: time.Now(),
			}
			jobs = append(jobs, current)
			continue
		}

		// Non-navigate steps attach to the current job.
		if current == nil {
			continue // no navigate yet — skip orphaned step
		}

		// Extract steps cannot be serialized — skip them.
		if step.Action == StepExtract {
			continue
		}

		// Collect steps are implemented as JS Evaluate steps.
		if step.Action == StepCollect {
			script := fmt.Sprintf(
				`() => [...document.querySelectorAll('%s')].map(el => el.getAttribute('%s')).filter(Boolean)`,
				step.Selector, step.Value,
			)
			js := foxhound.JobStep{
				Action:   foxhound.JobStepEvaluate,
				Script:   script,
				Selector: step.Selector,
				Value:    step.Value,
			}
			current.Steps = append(current.Steps, js)
			current.FetchMode = foxhound.FetchBrowser
			continue
		}

		js := foxhound.JobStep{
			Action:       mapStepAction(step.Action),
			Selector:     step.Selector,
			Duration:     step.Duration,
			MaxScrolls:   step.MaxScrolls,
			MaxClicks:    step.MaxClicks,
			MaxPages:     step.MaxPages,
			Script:       step.Script,
			StopSelector: step.StopSelector,
			StopCount:    step.StopCount,
			ScrollWait:   step.ScrollWait,
			Optional:     step.Optional,
			Value:        step.Value,
		}
		current.Steps = append(current.Steps, js)

		// Browser-only steps force FetchBrowser.
		current.FetchMode = foxhound.FetchBrowser
	}
	// Attach Trail-level adaptive registrations to every produced job's
	// Meta so the walker can apply them post-fetch.
	if len(t.adaptive) > 0 {
		regs := make([][2]string, len(t.adaptive))
		copy(regs, t.adaptive)
		for _, j := range jobs {
			if j.Meta == nil {
				j.Meta = make(map[string]any, 1)
			}
			j.Meta[trailAdaptiveMetaKey] = regs
		}
	}
	// Attach Trail-level XHR capture patterns to every produced job's Meta so
	// the camoufox fetcher can install per-fetch capture without a fetcher-level
	// option. Browser-mode is forced because XHR capture only works there.
	if len(t.captureXHR) > 0 {
		patterns := make([]string, len(t.captureXHR))
		copy(patterns, t.captureXHR)
		for _, j := range jobs {
			if j.Meta == nil {
				j.Meta = make(map[string]any, 1)
			}
			j.Meta[captureXHRMetaKey] = patterns
			j.FetchMode = foxhound.FetchBrowser
		}
	}
	return jobs
}

// captureXHRMetaKey mirrors fetch.CaptureXHRMetaKey. Duplicated as an
// unexported constant here to avoid an engine→fetch import dependency. The
// two values must stay in sync.
const captureXHRMetaKey = "_foxhound_capture_xhr"

// mapStepAction converts an engine.StepAction to the package-level JobStep*
// constant defined in foxhound.go. Only browser-executable actions (Click,
// Wait, Scroll) are mapped; Extract is skipped before reaching this function.
func mapStepAction(a StepAction) int {
	switch a {
	case StepClick:
		return foxhound.JobStepClick
	case StepWait:
		return foxhound.JobStepWait
	case StepScroll:
		return foxhound.JobStepScroll
	case StepInfiniteScroll:
		return foxhound.JobStepInfiniteScroll
	case StepLoadMore:
		return foxhound.JobStepLoadMore
	case StepPaginate:
		return foxhound.JobStepPaginate
	case StepEvaluate:
		return foxhound.JobStepEvaluate
	case StepFill:
		return foxhound.JobStepFill
	case StepCollect:
		return foxhound.JobStepEvaluate
	default:
		return foxhound.JobStepNavigate
	}
}
