// Package engine provides a mode-agnostic scan engine that can be used
// independently of any UI layer. This file implements the worker pool
// that performs concurrent path scanning using errgroup for coordination.
package engine

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"golang.org/x/sync/errgroup"
)

// maxAttempts is the maximum number of request attempts (original + retries).
const maxAttempts = 2

// defaultBackoff is the max backoff delay when Retry-After header is missing.
const defaultBackoff = 2 * time.Second

// maxConsecutiveConnErrors is the number of consecutive connection failures
// within a single domain before we bail out and skip remaining paths.
const maxConsecutiveConnErrors = 5

// bodyBufPool reuses 50KB buffers for reading response bodies,
// avoiding millions of allocations that churn GC during large scans.
var bodyBufPool = sync.Pool{
	New: func() interface{} {
		buf := make([]byte, 50*1024)
		return &buf
	},
}

// workerPool manages concurrent scanning of targets using errgroup.
// It is internal to the engine package and not exported.
type workerPool struct {
	cfg       Config
	client    *http.Client
	reporter  ProgressReporter
	stats     *Stats
	rateLimit *RateLimitState
	baselines *baselineCache
	deadHosts sync.Map // tracks domains confirmed unreachable
}

// newWorkerPool creates a new worker pool with the given configuration.
func newWorkerPool(cfg Config, client *http.Client, reporter ProgressReporter, stats *Stats, rateLimit *RateLimitState) *workerPool {
	return &workerPool{
		cfg:       cfg,
		client:    client,
		reporter:  reporter,
		stats:     stats,
		rateLimit: rateLimit,
		baselines: newBaselineCache(),
	}
}

// scanTargets scans all targets concurrently using errgroup with bounded concurrency.
// It respects context cancellation and reports progress via the ProgressReporter.
func (wp *workerPool) scanTargets(ctx context.Context, targets []string) error {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(wp.cfg.Workers)

	// Update stats with initial queue size
	wp.stats.SetQueueSize(int64(len(targets)))
	wp.stats.SetWorkersActive(int64(wp.cfg.Workers))

	for _, target := range targets {
		target := target // capture for goroutine

		g.Go(func() error {
			// Decrement queue size as we start processing
			wp.stats.SetQueueSize(wp.stats.Snapshot().QueueSize - 1)

			return wp.scanTarget(ctx, target)
		})
	}

	err := g.Wait()
	wp.stats.SetWorkersActive(0)
	wp.stats.SetQueueSize(0)
	return err
}

// scanTarget scans a single target domain, checking all credential paths.
// It reports progress and findings via the ProgressReporter callbacks.
// Uses dead host detection to skip unreachable domains instantly.
func (wp *workerPool) scanTarget(ctx context.Context, target string) error {
	// Clean target: remove protocol prefix and trailing slash
	target = strings.TrimPrefix(target, "http://")
	target = strings.TrimPrefix(target, "https://")
	target = strings.TrimRight(target, "/")

	startTime := time.Now()
	pathsChecked := 0
	findingsCount := 0

	// Report start
	wp.reporter.OnProgress(ProgressUpdate{
		Target:        target,
		Status:        StatusStarted,
		PathsChecked:  0,
		PathsTotal:    len(CredentialPaths),
		FindingsCount: 0,
		Elapsed:       0,
	})

	// FAST PATH: Probe domain reachability before checking 1107 paths.
	// If the domain is completely dead, skip everything instantly.
	if !wp.probeDomain(ctx, target) {
		wp.reporter.OnError(ErrorInfo{
			Target: target,
			Path:   "",
			Err:    fmt.Errorf("host unreachable: %s (skipped %d paths)", target, len(CredentialPaths)),
			At:     time.Now(),
		})
		wp.stats.IncrementErrors()
		wp.reporter.OnProgress(ProgressUpdate{
			Target:        target,
			Status:        StatusError,
			PathsChecked:  0,
			PathsTotal:    len(CredentialPaths),
			FindingsCount: 0,
			Elapsed:       time.Since(startTime),
		})
		return nil
	}

	consecutiveConnErrors := 0

	for _, path := range CredentialPaths {
		// CRITICAL: Check ctx.Done() BEFORE each request for clean cancellation
		select {
		case <-ctx.Done():
			// Report error status on cancellation
			wp.reporter.OnProgress(ProgressUpdate{
				Target:        target,
				Status:        StatusError,
				PathsChecked:  pathsChecked,
				PathsTotal:    len(CredentialPaths),
				FindingsCount: findingsCount,
				Elapsed:       time.Since(startTime),
			})
			return ctx.Err()
		default:
		}

		finding, err := wp.checkPath(ctx, target, path)
		pathsChecked++
		wp.stats.IncrementScanned()

		if err != nil {
			// Track consecutive connection errors for mid-scan bailout
			if isConnectionError(err) {
				consecutiveConnErrors++
				if consecutiveConnErrors >= maxConsecutiveConnErrors {
					wp.deadHosts.Store(target, struct{}{})
					wp.reporter.OnError(ErrorInfo{
						Target: target,
						Path:   path,
						Err:    fmt.Errorf("host went dead after %d consecutive failures, skipping remaining paths", consecutiveConnErrors),
						At:     time.Now(),
					})
					wp.stats.IncrementErrors()
					break
				}
			} else {
				consecutiveConnErrors = 0 // reset on non-connection error
			}

			wp.reporter.OnError(ErrorInfo{
				Target: target,
				Path:   path,
				Err:    err,
				At:     time.Now(),
			})
			wp.stats.IncrementErrors()
			continue
		}

		consecutiveConnErrors = 0 // reset on success

		if finding != nil && finding.Exposed {
			findingsCount++
			wp.stats.IncrementFound()
			wp.reporter.OnFinding(*finding)
		}
	}

	// Determine final status
	finalStatus := StatusCompleted
	if findingsCount > 0 {
		finalStatus = StatusFound
	}

	// Report completion
	wp.reporter.OnProgress(ProgressUpdate{
		Target:        target,
		Status:        finalStatus,
		PathsChecked:  pathsChecked,
		PathsTotal:    len(CredentialPaths),
		FindingsCount: findingsCount,
		Elapsed:       time.Since(startTime),
	})

	return nil
}

// probeDomain performs a fast reachability check before scanning all paths.
// Returns true if the domain responds to at least one scheme (HTTPS or HTTP).
// Results are cached in deadHosts to avoid re-probing known-dead domains.
func (wp *workerPool) probeDomain(ctx context.Context, domain string) bool {
	// Check dead host cache first
	if _, dead := wp.deadHosts.Load(domain); dead {
		return false
	}

	// Short timeout for probe — don't waste time on dead hosts
	probeCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	for _, scheme := range []string{"https", "http"} {
		url := fmt.Sprintf("%s://%s/", scheme, domain)
		req, err := http.NewRequestWithContext(probeCtx, "HEAD", url, nil)
		if err != nil {
			continue
		}
		req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")

		resp, err := wp.client.Do(req)
		if err != nil {
			continue
		}
		resp.Body.Close()
		return true // Domain is alive — any response counts
	}

	// Both schemes failed — mark dead
	wp.deadHosts.Store(domain, struct{}{})
	return false
}

// isConnectionError returns true if the error indicates the host is unreachable
// (as opposed to an HTTP-level error like 404/500 which means the host IS up).
// Uses errors.As() for type-safe detection, with string fallback for wrapped errors.
func isConnectionError(err error) bool {
	if err == nil {
		return false
	}

	// Type-safe checks — stable across Go versions
	var opErr *net.OpError
	if errors.As(err, &opErr) {
		return true
	}
	var dnsErr *net.DNSError
	if errors.As(err, &dnsErr) {
		return true
	}

	// String fallback for errors wrapped beyond net types
	s := err.Error()
	return strings.Contains(s, "connection refused") ||
		strings.Contains(s, "i/o timeout") ||
		strings.Contains(s, "network is unreachable") ||
		strings.Contains(s, "connection reset")
}

// getBaseline fetches the homepage for a domain and caches the content length
// for soft 404 detection. Returns cached baseline if available.
// Tries HTTPS first, falls back to HTTP. Caches failures as zero-length baseline.
func (wp *workerPool) getBaseline(ctx context.Context, domain string) baseline {
	// Check cache first
	if b, ok := wp.baselines.get(domain); ok {
		return b
	}

	// Try to fetch homepage
	schemes := []string{"https", "http"}
	for _, scheme := range schemes {
		url := fmt.Sprintf("%s://%s/", scheme, domain)
		req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
		if err != nil {
			continue
		}

		req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
		req.Header.Set("Accept", "*/*")

		resp, err := wp.client.Do(req)
		if err != nil {
			continue // Try next scheme
		}

		// Read body (same 50KB limit as checkPath)
		bufPtr := bodyBufPool.Get().(*[]byte)
		n, _ := io.ReadFull(resp.Body, *bufPtr)
		resp.Body.Close()
		bodyBufPool.Put(bufPtr)

		// Cache successful baseline
		b := baseline{
			contentLength: n,
			fetchedAt:     time.Now(),
		}
		wp.baselines.set(domain, b)
		return b
	}

	// Failed to fetch - cache as failed (fail closed to prevent false positives)
	b := baseline{
		contentLength: 0,
		fetchFailed:   true,
		fetchedAt:     time.Now(),
	}
	wp.baselines.set(domain, b)
	return b
}

// maxRetryAfter caps how long we'll wait on a 429 Retry-After header.
// Servers can request absurdly long waits — we skip the domain instead.
const maxRetryAfter = 3 * time.Second

// parseRetryAfter extracts retry delay from HTTP response.
// Supports numeric seconds (most common) and HTTP date format.
// Returns defaultBackoff if header is missing or unparseable.
// Capped at maxRetryAfter to prevent servers from stalling the scanner.
func parseRetryAfter(resp *http.Response) time.Duration {
	if resp == nil {
		return defaultBackoff
	}
	retryAfter := resp.Header.Get("Retry-After")
	if retryAfter == "" {
		return defaultBackoff
	}
	// Try numeric seconds first (most common)
	if seconds, err := strconv.Atoi(retryAfter); err == nil {
		d := time.Duration(seconds) * time.Second
		if d > maxRetryAfter {
			return maxRetryAfter
		}
		return d
	}
	// Try HTTP date format
	if t, err := http.ParseTime(retryAfter); err == nil {
		delay := time.Until(t)
		if delay > maxRetryAfter {
			return maxRetryAfter
		}
		if delay > 0 {
			return delay
		}
	}
	return defaultBackoff
}

// checkPath probes a single URL path and returns a Finding if credentials are exposed.
// Uses http.NewRequestWithContext for proper cancellation support.
// Implements retry logic for 429/502/503/504 responses with backoff.
func (wp *workerPool) checkPath(ctx context.Context, domain, path string) (*Finding, error) {
	// Try HTTPS first, fall back to HTTP
	schemes := []string{"https", "http"}

	wp.stats.IncrementInFlight()
	defer wp.stats.DecrementInFlight()

	for _, scheme := range schemes {
		url := fmt.Sprintf("%s://%s%s", scheme, domain, path)

		var lastErr error
		for attempt := 0; attempt < maxAttempts; attempt++ {
			req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
			if err != nil {
				lastErr = err
				break // Request creation failed, try next scheme
			}

			// Set realistic headers to avoid bot detection
			req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
			req.Header.Set("Accept", "*/*")

			resp, err := wp.client.Do(req)
			if err != nil {
				// Connection error - fail immediately, no retry per CONTEXT.md
				lastErr = err
				break // Try next scheme
			}

			// Check for rate limiting / server errors that warrant retry
			switch resp.StatusCode {
			case 429: // Too Many Requests
				delay := parseRetryAfter(resp)
				resp.Body.Close()
				wp.stats.IncrementRateLimited()
				// Only delay THIS worker's retry — don't freeze all 1000 workers
				select {
				case <-ctx.Done():
					return nil, ctx.Err()
				case <-time.After(delay):
					continue // Retry
				}

			case 502, 503, 504: // Bad Gateway, Service Unavailable, Gateway Timeout
				resp.Body.Close()
				// Exponential backoff: 1s, 2s (capped at defaultBackoff)
				backoff := time.Duration(1<<uint(attempt)) * time.Second
				if backoff > defaultBackoff {
					backoff = defaultBackoff
				}
				select {
				case <-ctx.Done():
					return nil, ctx.Err()
				case <-time.After(backoff):
					continue // Retry
				}
			}

			// For all other status codes, process the response
			// Read body (limit to 50KB to avoid huge responses)
			bufPtr := bodyBufPool.Get().(*[]byte)
			n, _ := io.ReadFull(resp.Body, *bufPtr)
			resp.Body.Close()
			body := string((*bufPtr)[:n])
			bodyBufPool.Put(bufPtr)

			finding := &Finding{
				Domain:      domain,
				Path:        path,
				StatusCode:  resp.StatusCode,
				ContentType: resp.Header.Get("Content-Type"),
				FoundAt:     time.Now(),
			}

			// Only analyze 200 OK responses for credential exposure
			if resp.StatusCode == 200 {
				// Stage 1: Content-Type validation (Issue #3)
				if !isValidContentType(path, resp.Header.Get("Content-Type")) {
					return finding, nil
				}

				// Stage 2: HTML body rejection (Issue #2)
				if containsHTMLIndicators(body) {
					return finding, nil
				}

				// Stage 3: Soft 404 detection (Issue #1)
				// Lazy fetch: only get baseline after passing Stages 1-2
				base, ok := wp.baselines.get(domain)
				if !ok {
					base = wp.getBaseline(ctx, domain)
				}
				if isSoft404(len(body), base) {
					return finding, nil
				}

				// Stage 4: Context-aware pattern matching (Issue #2)
				matched := matchPatternsContextAware(body, path)
				specificMatched := matchSpecificPatterns(body)
				matched = append(matched, specificMatched...)

				if len(matched) > 0 {
					finding.Exposed = true
					finding.Patterns = matched
					if len(body) > 500 {
						finding.BodySnippet = body[:500]
					} else {
						finding.BodySnippet = body
					}
				}
			}

			return finding, nil
		}

		// If we exhausted retries without returning, continue to next scheme
		if lastErr != nil {
			continue
		}
	}

	return nil, fmt.Errorf("failed to connect to %s", domain)
}

// createHTTPClient creates an HTTP client configured for high-speed scanning.
// Features: TLS skip verify, connection pooling, redirect limiting, dial/TLS timeouts.
func createHTTPClient(cfg Config) *http.Client {
	dialer := &net.Dialer{
		Timeout:   2 * time.Second,  // Fast-fail on unreachable hosts
		KeepAlive: 30 * time.Second,
	}

	transport := &http.Transport{
		TLSClientConfig:       &tls.Config{InsecureSkipVerify: true},
		DialContext:            dialer.DialContext,
		TLSHandshakeTimeout:   3 * time.Second,
		ResponseHeaderTimeout:  cfg.Timeout,
		MaxIdleConns:           cfg.Workers * 2,
		MaxIdleConnsPerHost:    32,
		MaxConnsPerHost:        64, // Prevent socket exhaustion
		IdleConnTimeout:        30 * time.Second,
		DisableKeepAlives:      false,
	}

	client := &http.Client{
		Timeout:   cfg.Timeout,
		Transport: transport,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 3 {
				return http.ErrUseLastResponse
			}
			return nil
		},
	}

	return client
}
