package project

import (
	"context"
	"fmt"
	"slices"
	"sync"
	"time"

	"github.com/microsoft/typescript-go/internal/ast"
	"github.com/microsoft/typescript-go/internal/checker"
	"github.com/microsoft/typescript-go/internal/compiler"
	"github.com/microsoft/typescript-go/internal/core"
	"github.com/microsoft/typescript-go/internal/debug"
)

// checkerHeldAnonymous is a sentinel stored in heldBy when a checker is held
// by a caller that has no request ID (e.g., context.Background()). This
// distinguishes "held without ID" from "not held" (empty string).
const checkerHeldAnonymous = "<anonymous>"

type CheckerPoolOptions struct {
	// MaxCheckers controls the total number of checker slots per project
	// (1 dedicated diagnostics checker + N-1 query checkers). Minimum 2.
	// Zero uses the default (4).
	MaxCheckers int
	// IdleTimeout controls how long an idle checker is kept
	// before being disposed. Zero uses the default (30s).
	IdleTimeout time.Duration
}

// checkerPool manages three categories of type checkers for a project:
//
//   - Diagnostics (index 0): A single checker for LSP diagnostics, providing
//     consistent walk order. Idle-cleaned.
//   - Temporary (indices 1+): Ephemeral query checkers for LSP operations.
//     Idle-cleaned after a configurable timeout.
//   - API: A single checker for API operations, providing stable
//     instance identity for reference equality on type/symbol handles.
//     Never idle-cleaned.
type checkerPool struct {
	opts    CheckerPoolOptions
	program *compiler.Program

	mu sync.Mutex

	// discarded is set when the pool's program has been replaced. The pool
	// remains fully functional but stops its idle-cleanup timer so that
	// query checkers are not disposed until the pool is GC'd.
	discarded bool

	// checkers[0] is the diagnostics checker.
	// checkers[1:] are ephemeral query checkers.
	// All are idle-cleaned.
	checkers            []*checker.Checker
	heldBy              []string                // heldBy[i] is the requestID holding checker i, checkerHeldAnonymous, or "" if not held
	fileAssociations    map[*ast.SourceFile]int // file → query checker index (1+)
	requestAssociations map[string]int          // requestID → checker index

	// lastReleased tracks when each checker was last released.
	lastReleased []time.Time

	// cleanupTimer is reset each time a checker is released.
	// When it fires, idle checkers are disposed.
	cleanupTimer *time.Timer

	// persistentChecker is the API checker. It is never idle-cleaned,
	// providing stable instance identity for API clients.
	persistentChecker *checker.Checker
	persistentHeld    bool

	diagSem       chan struct{}
	querySem      chan struct{}
	persistentSem chan struct{}

	log                    func(msg string)
	globalDiagAccumulated  []*ast.Diagnostic
	globalDiagChanged      bool
	globalDiagCheckerCount []int // per-checker count of globals last seen
}

var _ compiler.CheckerPool = (*checkerPool)(nil)

func newCheckerPool(opts CheckerPoolOptions, program *compiler.Program, log func(msg string)) *checkerPool {
	if opts.MaxCheckers <= 0 {
		opts.MaxCheckers = 4
	} else if opts.MaxCheckers < 2 {
		opts.MaxCheckers = 2 // at least 1 diagnostics + 1 query checker
	}
	if opts.IdleTimeout <= 0 {
		opts.IdleTimeout = 30 * time.Second
	}
	querySlots := opts.MaxCheckers - 1
	pool := &checkerPool{
		program:                program,
		opts:                   opts,
		checkers:               make([]*checker.Checker, opts.MaxCheckers),
		heldBy:                 make([]string, opts.MaxCheckers),
		fileAssociations:       make(map[*ast.SourceFile]int),
		requestAssociations:    make(map[string]int),
		lastReleased:           make([]time.Time, opts.MaxCheckers),
		diagSem:                make(chan struct{}, 1),
		querySem:               make(chan struct{}, querySlots),
		persistentSem:          make(chan struct{}, 1),
		log:                    log,
		globalDiagCheckerCount: make([]int, opts.MaxCheckers),
	}

	if pool.log == nil {
		pool.log = func(msg string) {}
	}
	return pool
}

// holdTag returns the value to store in heldBy for the given request ID.
func holdTag(requestID string) string {
	if requestID == "" {
		return checkerHeldAnonymous
	}
	return requestID
}

func (p *checkerPool) GetChecker(ctx context.Context, file *ast.SourceFile) (*checker.Checker, func()) {
	lifetime := core.GetCheckerLifetime(ctx)
	requestID := core.GetRequestID(ctx)

	// Request affinity is cleaned up via context.AfterFunc when the request
	// context is done. If the context can never be canceled (ctx.Done() == nil,
	// e.g. context.Background()), that cleanup would never run and
	// requestAssociations would grow unboundedly, so disable affinity entirely.
	if ctx.Done() == nil {
		requestID = ""
	}

	switch lifetime {
	case core.CheckerLifetimeDiagnostics:
		return p.getDiagnosticsChecker(ctx, requestID)
	case core.CheckerLifetimeAPI:
		return p.getPersistentChecker()
	default:
		return p.getQueryChecker(ctx, requestID, file)
	}
}

// tryReacquireForRequest checks whether the given request already has an
// associated checker. If so, it either returns the checker directly (still held)
// or reacquires it by claiming a semaphore slot. The caller must provide the
// appropriate semaphore channel and indicate whether this is a diagnostics
// request (isDiag). If the associated checker is in the wrong category
// (e.g. a diagnostics index for a query request), the association is deleted
// and normal acquisition proceeds.
//
// Returns (checker, release, true) if the request was served (either still held
// or reclaimed). Returns (nil, nil, false) if the caller must proceed with
// normal acquisition — in this case, a semaphore slot has already been claimed.
// Must NOT be called with p.mu held.
func (p *checkerPool) tryReacquireForRequest(requestID string, sem chan<- struct{}, isDiag bool) (*checker.Checker, func(), bool) {
	if requestID == "" {
		sem <- struct{}{}
		return nil, nil, false
	}

	p.mu.Lock()
	index, ok := p.requestAssociations[requestID]
	if !ok {
		p.mu.Unlock()
		sem <- struct{}{}
		return nil, nil, false
	}

	// Validate that the associated index matches the expected category.
	// Index 0 is for diagnostics; indices 1+ are for queries.
	if (isDiag && index != 0) || (!isDiag && index == 0) {
		delete(p.requestAssociations, requestID)
		p.mu.Unlock()
		sem <- struct{}{}
		return nil, nil, false
	}

	c := p.checkers[index]
	if c == nil {
		delete(p.requestAssociations, requestID)
		p.mu.Unlock()
		sem <- struct{}{}
		return nil, nil, false
	}

	held := p.heldBy[index]
	if held == requestID {
		// Same request, checker still held — return without claiming a slot.
		p.mu.Unlock()
		return c, noop, true
	}

	if held == "" {
		// Same request reacquiring after release — need a semaphore slot.
		p.mu.Unlock()
		sem <- struct{}{}
		p.mu.Lock()
		// Re-check: checker may have been disposed while waiting for the slot.
		if cc := p.checkers[index]; cc == c && p.heldBy[index] == "" {
			p.heldBy[index] = requestID
			p.mu.Unlock()
			return c, p.createRelease(requestID, index, c), true
		}
		p.mu.Unlock()
		// Checker was replaced/disposed while waiting for the slot.
		// The slot is still claimed; the caller will use it for normal acquisition.
		return nil, nil, false
	}

	// Checker held by another request — claim a slot normally.
	p.mu.Unlock()
	sem <- struct{}{}
	return nil, nil, false
}

// getDiagnosticsChecker returns the dedicated diagnostics checker (index 0).
// Creates it on first use. Blocks on diagSem if it's currently in use.
func (p *checkerPool) getDiagnosticsChecker(ctx context.Context, requestID string) (*checker.Checker, func()) {
	const diagIndex = 0

	if c, release, ok := p.tryReacquireForRequest(requestID, p.diagSem, true); ok {
		return c, release
	}

	// Token consumed — proceed with normal acquisition.
	p.mu.Lock()
	defer p.mu.Unlock()

	if p.checkers[diagIndex] == nil {
		p.log("checkerpool: Creating diagnostics checker")
		c, _ := checker.NewChecker(p.program, nil)
		p.checkers[diagIndex] = c
	}

	c := p.checkers[diagIndex]
	p.heldBy[diagIndex] = holdTag(requestID)
	p.log("checkerpool: Acquired diagnostics checker for request " + holdTag(requestID))
	if requestID != "" {
		if _, alreadyRegistered := p.requestAssociations[requestID]; !alreadyRegistered {
			p.requestAssociations[requestID] = diagIndex
			p.registerRequestCleanup(ctx, requestID)
		}
	}
	return c, p.createRelease(requestID, diagIndex, c)
}

// getQueryChecker returns an ephemeral query checker from indices 1+.
// Uses request affinity, then file affinity, then finds/creates.
// Blocks on querySem if all query slots are in use.
func (p *checkerPool) getQueryChecker(ctx context.Context, requestID string, file *ast.SourceFile) (*checker.Checker, func()) {
	if c, release, ok := p.tryReacquireForRequest(requestID, p.querySem, false); ok {
		return c, release
	}

	// Token consumed — proceed with normal acquisition.
	p.mu.Lock()
	defer p.mu.Unlock()

	// Try file affinity.
	if file != nil {
		if index, ok := p.fileAssociations[file]; ok && index > 0 {
			if c := p.checkers[index]; c != nil && p.heldBy[index] == "" {
				p.heldBy[index] = holdTag(requestID)
				if requestID != "" {
					if _, alreadyRegistered := p.requestAssociations[requestID]; !alreadyRegistered {
						p.requestAssociations[requestID] = index
						p.registerRequestCleanup(ctx, requestID)
					}
				}
				return c, p.createRelease(requestID, index, c)
			}
		}
	}

	// Find any available query checker or create one.
	c, index := p.findOrCreateQueryCheckerLocked()
	p.heldBy[index] = holdTag(requestID)
	p.log(fmt.Sprintf("checkerpool: Acquired query checker %d for request %s", index, holdTag(requestID)))
	if requestID != "" {
		if _, alreadyRegistered := p.requestAssociations[requestID]; !alreadyRegistered {
			p.requestAssociations[requestID] = index
			p.registerRequestCleanup(ctx, requestID)
		}
	}
	if file != nil {
		p.fileAssociations[file] = index
	}
	return c, p.createRelease(requestID, index, c)
}

// findOrCreateQueryCheckerLocked returns an idle query checker or creates one
// in the first empty slot. The semaphore guarantees at least one slot is
// available. Must be called with p.mu held.
func (p *checkerPool) findOrCreateQueryCheckerLocked() (*checker.Checker, int) {
	// Prefer an existing idle checker.
	for i := 1; i < len(p.checkers); i++ {
		if c := p.checkers[i]; c != nil && p.heldBy[i] == "" {
			return c, i
		}
	}
	// Create in the first empty slot.
	for i := 1; i < len(p.checkers); i++ {
		if p.checkers[i] == nil {
			p.log(fmt.Sprintf("checkerpool: Creating query checker %d", i))
			c, _ := checker.NewChecker(p.program, nil)
			p.checkers[i] = c
			return c, i
		}
	}
	panic("checkerpool: no available query slot despite holding semaphore token")
}

func (p *checkerPool) getPersistentChecker() (*checker.Checker, func()) {
	p.persistentSem <- struct{}{}
	p.mu.Lock()

	if p.persistentChecker == nil {
		p.log("checkerpool: Creating persistent checker")
		c, _ := checker.NewChecker(p.program, nil)
		p.persistentChecker = c
	}

	c := p.persistentChecker
	p.persistentHeld = true
	p.mu.Unlock()

	return c, sync.OnceFunc(func() {
		p.mu.Lock()
		p.persistentHeld = false
		if c.WasCanceled() {
			// A canceled checker panics on reuse, so drop it; the next API
			// acquisition will create a fresh persistent checker.
			p.log("checkerpool: Persistent checker was canceled, disposing")
			if p.persistentChecker == c {
				p.persistentChecker = nil
			}
		}
		p.mu.Unlock()
		<-p.persistentSem
	})
}

func (p *checkerPool) createRelease(requestID string, index int, c *checker.Checker) func() {
	return sync.OnceFunc(func() {
		p.mu.Lock()

		if c.WasCanceled() {
			// Canceled checkers must be disposed.
			p.log(fmt.Sprintf("checkerpool: Checker %d for request %s was canceled, disposing", index, holdTag(requestID)))
			p.disposeCheckerLocked(index, c)
		} else {
			p.mergeGlobalDiagnosticsFromCheckerLocked(index, c)
			p.heldBy[index] = ""
			p.lastReleased[index] = time.Now()
			if !p.discarded {
				p.scheduleCleanupLocked()
			}
			// If discarded, skip scheduling cleanup — checkers stay alive
			// until the pool is garbage collected so that API clients can
			// continue resolving type/symbol handles.
		}

		// Unlock before releasing the semaphore slot. If we received from
		// the channel while holding p.mu, a woken goroutine could immediately
		// try to acquire p.mu, risking priority inversion or unnecessary
		// contention.
		p.mu.Unlock()

		// Release the semaphore slot.
		if index == 0 {
			<-p.diagSem
		} else {
			<-p.querySem
		}
	})
}

// registerRequestCleanup uses context.AfterFunc to delete the request
// association when the request context is done. This prevents the map
// from growing unboundedly with completed request IDs.
// Must be called with p.mu held; the cleanup runs asynchronously.
func (p *checkerPool) registerRequestCleanup(ctx context.Context, requestID string) {
	context.AfterFunc(ctx, func() {
		p.mu.Lock()
		defer p.mu.Unlock()
		delete(p.requestAssociations, requestID)
	})
}

// scheduleCleanupLocked resets (or starts) the cleanup timer so it fires at
// the earliest pending checker-expiration deadline among all currently idle,
// unheld checkers.
// Must be called with p.mu held. Must NOT be called on discarded pools.
func (p *checkerPool) scheduleCleanupLocked() {
	var earliestDeadline time.Time
	for i := range p.checkers {
		if p.checkers[i] == nil || p.heldBy[i] != "" || p.lastReleased[i].IsZero() {
			continue
		}
		deadline := p.lastReleased[i].Add(p.opts.IdleTimeout)
		if earliestDeadline.IsZero() || deadline.Before(earliestDeadline) {
			earliestDeadline = deadline
		}
	}
	if earliestDeadline.IsZero() {
		// No idle checkers remain — stop the timer if it exists.
		if p.cleanupTimer != nil {
			p.cleanupTimer.Stop()
			p.cleanupTimer = nil
		}
		return
	}
	delay := time.Until(earliestDeadline)
	if delay <= 0 {
		delay = time.Millisecond
	}
	if p.cleanupTimer != nil {
		p.cleanupTimer.Reset(delay)
	} else {
		p.cleanupTimer = time.AfterFunc(delay, p.cleanupIdleCheckers)
	}
}

// cleanupIdleCheckers disposes checkers that have been idle for longer than
// the idle timeout. The API checker is separate and never idle-cleaned.
func (p *checkerPool) cleanupIdleCheckers() {
	p.mu.Lock()
	defer p.mu.Unlock()
	// The timer callback may already have been in flight when Discard() called
	// Stop() (which does not guarantee the callback won't run). Bail out without
	// rescheduling so a discarded pool doesn't keep itself alive via a new timer.
	if p.discarded {
		return
	}
	now := time.Now()
	for i := range p.checkers {
		c := p.checkers[i]
		if c == nil || p.heldBy[i] != "" {
			continue
		}
		if p.lastReleased[i].IsZero() {
			continue
		}
		idle := now.Sub(p.lastReleased[i])
		if idle >= p.opts.IdleTimeout {
			p.log(fmt.Sprintf("checkerpool: Disposing idle checker %d (idle %v)", i, idle))
			p.disposeCheckerLocked(i, c)
		}
	}
	// Reschedule for any remaining idle-but-not-yet-expired checkers.
	// scheduleCleanupLocked will Reset the existing timer rather than
	// creating a new one, avoiding goroutine leaks.
	p.scheduleCleanupLocked()
}

// disposeCheckerLocked removes a checker from the pool and clears all associations
// (file and request) that reference it. Must be called with p.mu held.
func (p *checkerPool) disposeCheckerLocked(index int, c *checker.Checker) {
	debug.Assert(p.checkers[index] == c)
	p.checkers[index] = nil
	p.heldBy[index] = ""
	p.globalDiagCheckerCount[index] = 0
	p.lastReleased[index] = time.Time{}
	for file, idx := range p.fileAssociations {
		if idx == index {
			delete(p.fileAssociations, file)
		}
	}
	for req, idx := range p.requestAssociations {
		if idx == index {
			delete(p.requestAssociations, req)
		}
	}
}

// mergeGlobalDiagnosticsFromCheckerLocked checks if the given checker has produced new global
// diagnostics since the last time we looked, and if so merges them into the accumulated set.
// Must be called with p.mu held.
func (p *checkerPool) mergeGlobalDiagnosticsFromCheckerLocked(index int, c *checker.Checker) {
	globals := c.GetGlobalDiagnostics()
	if len(globals) == p.globalDiagCheckerCount[index] {
		return
	}
	p.globalDiagCheckerCount[index] = len(globals)
	before := len(p.globalDiagAccumulated)
	p.globalDiagAccumulated = compiler.SortAndDeduplicateDiagnostics(append(p.globalDiagAccumulated, globals...))
	if len(p.globalDiagAccumulated) != before {
		p.globalDiagChanged = true
	}
}

// GetGlobalDiagnostics returns the accumulated global diagnostics collected from
// all checkers that have been used so far in this pool's lifetime.
func (p *checkerPool) GetGlobalDiagnostics() []*ast.Diagnostic {
	p.mu.Lock()
	defer p.mu.Unlock()
	return slices.Clone(p.globalDiagAccumulated)
}

// TakeNewGlobalDiagnostics reports whether new global diagnostics have been
// accumulated since the last call, and resets the flag.
func (p *checkerPool) TakeNewGlobalDiagnostics() bool {
	p.mu.Lock()
	defer p.mu.Unlock()
	changed := p.globalDiagChanged
	p.globalDiagChanged = false
	return changed
}

// Discard signals that this pool's program has been replaced. The pool
// remains functional but stops its idle-cleanup timer so that checkers
// are not disposed until the pool is GC'd. The API checker is unaffected
// since it is never idle-cleaned.
func (p *checkerPool) Discard() {
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.discarded {
		return // already discarded
	}
	p.log("checkerpool: Discarding pool, stopping idle cleanup")
	p.discarded = true
	if p.cleanupTimer != nil {
		p.cleanupTimer.Stop()
		p.cleanupTimer = nil
	}
}

func noop() {}
