// Package browser owns the lifecycle of headless Chromium sessions used by
// the web agent plugin's webBrowser* tools. Each build session may hold at
// most one browser session at a time, capped at MaxActionsPerSession actions
// and reaped after MaxIdle of inactivity.
package browser

import (
	"crypto/rand"
	"encoding/hex"
	"sync"
	"time"
)

// Config tunes the SessionManager.
type Config struct {
	MaxIdle              time.Duration
	MaxActionsPerSession int
	// ActionTimeout caps each chromedp action (click, type, wait, navigate).
	// Defaults to 30s when zero.
	ActionTimeout time.Duration
}

// ManagerError standardizes manager-level errors.
type ManagerError struct {
	Code    string
	Message string
}

func (e *ManagerError) Error() string { return e.Code + ": " + e.Message }

// Manager owns the lifecycle of headless browser sessions, enforces the
// per-build-session cap of 1, and reaps idle sessions in the background.
//
// A single FilteringProxy is shared across all sessions — Chrome is wired
// through it via --proxy-server so every page-tier network call (redirects,
// sub-resources, fetch/XHR, WebSocket) is validated by urlguard.Validate.
// One proxy per builder process is enough; urlguard is stateless.
type Manager struct {
	mu       sync.Mutex
	cfg      Config
	sessions map[string]*Session
	perBuild map[string]string // build_session_id -> browser session_id
	stop     chan struct{}
	proxy    *FilteringProxy
}

// NewManager builds a Manager. Start the background reaper with StartReaper().
func NewManager(cfg Config) *Manager {
	if cfg.MaxIdle == 0 {
		cfg.MaxIdle = 60 * time.Second
	}
	if cfg.MaxActionsPerSession == 0 {
		cfg.MaxActionsPerSession = 50
	}
	if cfg.ActionTimeout == 0 {
		cfg.ActionTimeout = 30 * time.Second
	}
	return &Manager{
		cfg:      cfg,
		sessions: map[string]*Session{},
		perBuild: map[string]string{},
		stop:     make(chan struct{}),
	}
}

// StartReaper launches a goroutine that closes idle sessions every 10 seconds,
// and starts the shared filtering proxy that Chrome routes through.
// Call StopReaper to shut both down (e.g., on server shutdown).
func (m *Manager) StartReaper() {
	if proxy, err := StartFilteringProxy(); err == nil {
		m.mu.Lock()
		m.proxy = proxy
		m.mu.Unlock()
	}
	go m.reaperLoop()
}

// StopReaper stops the background reaper goroutine and tears down the
// shared filtering proxy.
func (m *Manager) StopReaper() {
	select {
	case <-m.stop:
		// already closed
	default:
		close(m.stop)
	}
	m.mu.Lock()
	p := m.proxy
	m.proxy = nil
	m.mu.Unlock()
	if p != nil {
		_ = p.Close()
	}
}

func (m *Manager) reaperLoop() {
	t := time.NewTicker(10 * time.Second)
	defer t.Stop()
	for {
		select {
		case <-m.stop:
			return
		case now := <-t.C:
			m.reapIdle(now)
		}
	}
}

func (m *Manager) reapIdle(now time.Time) {
	m.mu.Lock()
	defer m.mu.Unlock()
	for id, s := range m.sessions {
		if now.Sub(s.LastUsed()) > m.cfg.MaxIdle {
			s.close()
			delete(m.sessions, id)
			delete(m.perBuild, s.BuildSessionID)
		}
	}
}

// Open starts a new chromedp session and navigates to url. Returns the Session.
// Returns session_cap_reached if the build session already has an open browser.
//
// Uses a reserve-slot pattern to enforce the per-build cap atomically across
// concurrent callers: the perBuild slot is claimed under lock BEFORE the slow
// Chrome spawn so a second concurrent Open for the same buildSessionID sees
// the slot is taken and fails fast. On error during spawn/navigate, the slot
// is released.
func (m *Manager) Open(buildSessionID, url string) (*Session, error) {
	m.mu.Lock()
	if _, exists := m.perBuild[buildSessionID]; exists {
		m.mu.Unlock()
		return nil, &ManagerError{Code: "session_cap_reached", Message: "only 1 browser session allowed per build session"}
	}
	sessionID := newSessionID()
	m.perBuild[buildSessionID] = sessionID // reserve slot before slow spawn
	m.mu.Unlock()

	releaseSlot := func() {
		m.mu.Lock()
		if cur, ok := m.perBuild[buildSessionID]; ok && cur == sessionID {
			delete(m.perBuild, buildSessionID)
		}
		delete(m.sessions, sessionID)
		m.mu.Unlock()
	}

	m.mu.Lock()
	proxyURL := ""
	if m.proxy != nil {
		proxyURL = m.proxy.URL()
	}
	m.mu.Unlock()

	s, err := newSession(sessionID, buildSessionID, m.cfg.MaxActionsPerSession, m.cfg.ActionTimeout, proxyURL)
	if err != nil {
		releaseSlot()
		return nil, err
	}
	if err := s.Navigate(url); err != nil {
		s.close()
		releaseSlot()
		return nil, err
	}
	m.mu.Lock()
	m.sessions[sessionID] = s
	m.mu.Unlock()
	return s, nil
}

// Get returns a session by ID, bumping LastUsedAt.
func (m *Manager) Get(sessionID string) (*Session, error) {
	m.mu.Lock()
	s, ok := m.sessions[sessionID]
	m.mu.Unlock()
	if !ok {
		return nil, &ManagerError{Code: "session_not_found", Message: "session " + sessionID}
	}
	s.mu.Lock()
	s.touchUnlocked()
	s.mu.Unlock()
	return s, nil
}

// Close terminates a session by ID.
func (m *Manager) Close(sessionID string) error {
	m.mu.Lock()
	defer m.mu.Unlock()
	s, ok := m.sessions[sessionID]
	if !ok {
		return &ManagerError{Code: "session_not_found", Message: sessionID}
	}
	s.close()
	delete(m.sessions, sessionID)
	delete(m.perBuild, s.BuildSessionID)
	return nil
}

// CleanupForBuild closes all sessions associated with a build session.
// Called when a build session ends.
func (m *Manager) CleanupForBuild(buildSessionID string) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if sid, ok := m.perBuild[buildSessionID]; ok {
		if s := m.sessions[sid]; s != nil {
			s.close()
		}
		delete(m.sessions, sid)
		delete(m.perBuild, buildSessionID)
	}
}

func newSessionID() string {
	b := make([]byte, 12)
	_, _ = rand.Read(b)
	return "br_" + hex.EncodeToString(b)
}

// --- test helpers (unexported, used only by manager_test.go in the same package) ---

func (m *Manager) registerForTest(sessionID, buildID string) *Session {
	m.mu.Lock()
	defer m.mu.Unlock()
	s := &Session{
		ID:             sessionID,
		BuildSessionID: buildID,
		lastUsedAt:     time.Now(),
		maxActions:     m.cfg.MaxActionsPerSession,
	}
	m.sessions[sessionID] = s
	m.perBuild[buildID] = sessionID
	return s
}

func (m *Manager) openForTest(sessionID, buildID string) (*Session, error) {
	m.mu.Lock()
	defer m.mu.Unlock()
	if _, exists := m.perBuild[buildID]; exists {
		return nil, &ManagerError{Code: "session_cap_reached", Message: "cap"}
	}
	s := &Session{ID: sessionID, BuildSessionID: buildID, lastUsedAt: time.Now(), maxActions: m.cfg.MaxActionsPerSession}
	m.sessions[sessionID] = s
	m.perBuild[buildID] = sessionID
	return s, nil
}

func (m *Manager) bumpActionForTest(s *Session) error {
	return s.bumpAction()
}
