package browser

import (
	"context"
	"sync"
	"time"

	"github.com/chromedp/chromedp"
	"webby-builder/internal/security/urlguard"
)

// Session is a chromedp browser context owned by Manager.
// All mutable fields are guarded by mu. The reaper reads LastUsedAt
// concurrently with action handlers, so all access goes through bumpAction
// or LastUsed/SetLastUsed helpers.
type Session struct {
	ID             string
	BuildSessionID string

	mu            sync.Mutex
	lastUsedAt    time.Time
	maxActions    int
	actionCount   int
	actionTimeout time.Duration

	allocCtx    context.Context
	allocCancel context.CancelFunc
	ctx         context.Context
	cancel      context.CancelFunc
}

// runWithTimeout executes a chromedp action chain under a per-action
// deadline so a hung selector wait or stalled Chrome cannot block the
// goroutine past actionTimeout. Default 30s if unset.
func (s *Session) runWithTimeout(actions ...chromedp.Action) error {
	timeout := s.actionTimeout
	if timeout <= 0 {
		timeout = 30 * time.Second
	}
	ctx, cancel := context.WithTimeout(s.ctx, timeout)
	defer cancel()
	return chromedp.Run(ctx, actions...)
}

// LastUsed returns the time of the last bump, atomically.
func (s *Session) LastUsed() time.Time {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.lastUsedAt
}

// touchUnlocked sets lastUsedAt; caller must hold s.mu.
func (s *Session) touchUnlocked() {
	s.lastUsedAt = time.Now()
}

// privateIPHostResolverRules tells Chrome's network stack to fail DNS
// resolution for any private/loopback/link-local address. This is the only
// way to stop Chrome from connecting to internal hosts on its own — the Go
// SafeDialer only guards the CDP control socket, not Chrome's outbound
// fetches (page loads, redirects, sub-resources, fetch/XHR, WebSocket).
//
// 172.16.0.0/12 is enumerated explicitly because Chrome's MAP wildcard does
// not support CIDR. fc00::/7 and fe80::/10 are matched by prefix.
const privateIPHostResolverRules = "MAP 10.* ~NOTFOUND," +
	"MAP 127.* ~NOTFOUND," +
	"MAP 169.254.* ~NOTFOUND," +
	"MAP 192.168.* ~NOTFOUND," +
	"MAP 172.16.* ~NOTFOUND,MAP 172.17.* ~NOTFOUND,MAP 172.18.* ~NOTFOUND," +
	"MAP 172.19.* ~NOTFOUND,MAP 172.20.* ~NOTFOUND,MAP 172.21.* ~NOTFOUND," +
	"MAP 172.22.* ~NOTFOUND,MAP 172.23.* ~NOTFOUND,MAP 172.24.* ~NOTFOUND," +
	"MAP 172.25.* ~NOTFOUND,MAP 172.26.* ~NOTFOUND,MAP 172.27.* ~NOTFOUND," +
	"MAP 172.28.* ~NOTFOUND,MAP 172.29.* ~NOTFOUND,MAP 172.30.* ~NOTFOUND," +
	"MAP 172.31.* ~NOTFOUND," +
	"MAP 0.* ~NOTFOUND," +
	"MAP [::1] ~NOTFOUND," +
	"MAP [fc*] ~NOTFOUND,MAP [fd*] ~NOTFOUND,MAP [fe8*] ~NOTFOUND,MAP [fe9*] ~NOTFOUND," +
	"MAP [feA*] ~NOTFOUND,MAP [feB*] ~NOTFOUND"

func newSession(id, buildID string, maxActions int, actionTimeout time.Duration, proxyURL string) (*Session, error) {
	opts := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.Headless,
		chromedp.DisableGPU,
		chromedp.NoSandbox,
		chromedp.Flag("disable-dev-shm-usage", true),
		chromedp.Flag("disable-features", "site-per-process"),
		// SSRF defense-in-depth layer 1: kill Chrome's ability to resolve
		// private IPs at the resolver level. Catches DNS-name → private IP
		// (including DNS rebinding which Chrome re-resolves per fetch).
		chromedp.Flag("host-resolver-rules", privateIPHostResolverRules),
	)
	// SSRF defense-in-depth layer 2: route ALL Chrome page-tier traffic
	// (redirects, sub-resources, fetch/XHR, WebSocket, IP literals) through
	// an in-process FilteringProxy that runs urlguard.Validate on every
	// request. Belt-and-suspenders with the resolver rules above.
	if proxyURL != "" {
		opts = append(opts, chromedp.ProxyServer(proxyURL))
	}
	allocCtx, allocCancel := chromedp.NewExecAllocator(context.Background(), opts...)
	ctx, cancel := chromedp.NewContext(allocCtx)
	// Force start by running a no-op task; surfaces "chrome not found" early.
	if err := chromedp.Run(ctx); err != nil {
		cancel()
		allocCancel()
		return nil, &ManagerError{Code: "chrome_unavailable", Message: err.Error()}
	}
	return &Session{
		ID:             id,
		BuildSessionID: buildID,
		lastUsedAt:     time.Now(),
		maxActions:     maxActions,
		actionTimeout:  actionTimeout,
		allocCtx:       allocCtx,
		allocCancel:    allocCancel,
		ctx:            ctx,
		cancel:         cancel,
	}, nil
}

func (s *Session) close() {
	if s.cancel != nil {
		s.cancel()
	}
	if s.allocCancel != nil {
		s.allocCancel()
	}
}

func (s *Session) bumpAction() error {
	s.mu.Lock()
	defer s.mu.Unlock()
	// Check before increment — at exactly maxActions the next call must fail.
	if s.actionCount >= s.maxActions {
		return &ManagerError{Code: "action_cap_reached", Message: "max actions per session reached"}
	}
	s.actionCount++
	s.touchUnlocked()
	return nil
}

// Navigate visits a URL after re-validating SSRF safety.
func (s *Session) Navigate(url string) error {
	if err := urlguard.Validate(url); err != nil {
		return &ManagerError{Code: "ssrf_blocked", Message: err.Error()}
	}
	if err := s.bumpAction(); err != nil {
		return err
	}
	if s.ctx == nil {
		return &ManagerError{Code: "chrome_unavailable", Message: "session has no chrome context"}
	}
	return s.runWithTimeout(chromedp.Navigate(url))
}
