// Package proc provides a unified process manager for the AgentTeam dev tool and
// desktop daemon. It launches child processes in their own process groups,
// prefixes their stdout/stderr output, and orchestrates graceful shutdown
// (SIGTERM → SIGKILL) on Ctrl+C or context cancellation.
package proc

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"os"
	"os/exec"
	"strings"
	"sync"
	"time"
)

// DefaultStopTimeout is how long Manager waits after SIGTERM before SIGKILL.
const DefaultStopTimeout = 5 * time.Second

// Spec describes a process Manager should launch and supervise.
type Spec struct {
	// Name is the log prefix and identifier for the process.
	Name string
	// Args is the command and its arguments (Args[0] is the binary).
	Args []string
	// Dir is the working directory. Empty means inherit.
	Dir string
	// Env is the full environment. Empty means inherit.
	Env []string
	// ReadyURL, if set, is polled (with /readyz appended) until the process
	// responds 200 before Start returns.
	ReadyURL string
	// ReadyTimeout caps how long Start will wait for ReadyURL. Zero defaults to 120s.
	ReadyTimeout time.Duration
	// StopTimeout is the grace period between SIGTERM and SIGKILL. Zero defaults to 5s.
	StopTimeout time.Duration
	// Setpgid, if true (the default), launches the process in its own process
	// group so signals don't propagate from the parent.
	Setpgid *bool
	// Stdout, if non-nil, overrides Manager's default sink for prefixed output.
	Stdout io.Writer
}

// Process is a running child tracked by Manager.
type Process struct {
	Name   string
	Cmd    *exec.Cmd
	Done   <-chan struct{}
	spec   Spec
	prefix *prefixWriter

	done     chan struct{}
	exitOnce sync.Once
	stopOnce sync.Once
	exitMu   sync.RWMutex
	exitErr  error
}

// ExitErr returns the process's exit error after Done is closed. The result is
// retained, so any number of observers can inspect it without consuming the
// completion notification.
func (p *Process) ExitErr() error {
	if p == nil {
		return nil
	}
	p.exitMu.RLock()
	defer p.exitMu.RUnlock()
	return p.exitErr
}

func (p *Process) finish(err error) {
	p.exitOnce.Do(func() {
		p.exitMu.Lock()
		p.exitErr = err
		p.exitMu.Unlock()
		close(p.done)
	})
}

// Manager owns a set of child processes and coordinates their shutdown.
type Manager struct {
	out   io.Writer
	mu    sync.Mutex
	procs []*Process
}

// New returns a Manager that writes prefixed process output to out (os.Stderr
// when nil).
func New(out io.Writer) *Manager {
	if out == nil {
		out = os.Stderr
	}
	return &Manager{out: out}
}

// Output returns the writer used for prefixed process logs.
func (m *Manager) Output() io.Writer {
	return m.out
}

// Start launches spec as a child process. When spec.ReadyURL is set, Start
// blocks until the process is ready or fails.
func (m *Manager) Start(ctx context.Context, spec Spec) (*Process, error) {
	if len(spec.Args) == 0 {
		return nil, errors.New("proc: Spec.Args is empty")
	}
	if spec.StopTimeout == 0 {
		spec.StopTimeout = DefaultStopTimeout
	}
	if spec.ReadyTimeout == 0 {
		spec.ReadyTimeout = 120 * time.Second
	}
	setpgid := true
	if spec.Setpgid != nil {
		setpgid = *spec.Setpgid
	}

	cmd := exec.Command(spec.Args[0], spec.Args[1:]...)
	if spec.Dir != "" {
		cmd.Dir = spec.Dir
	}
	if len(spec.Env) > 0 {
		cmd.Env = spec.Env
	}
	dst := spec.Stdout
	if dst == nil {
		dst = m.out
	}
	prefix := newPrefixWriter(dst, spec.Name)
	cmd.Stdout = prefix
	cmd.Stderr = prefix
	if setpgid {
		ConfigureProcessGroup(cmd)
	}
	if err := cmd.Start(); err != nil {
		return nil, fmt.Errorf("start %s: %w", spec.Name, err)
	}
	done := make(chan struct{})
	p := &Process{
		Name:   spec.Name,
		Cmd:    cmd,
		Done:   done,
		spec:   spec,
		prefix: prefix,
		done:   done,
	}
	go func() { p.finish(cmd.Wait()) }()
	m.mu.Lock()
	m.procs = append(m.procs, p)
	m.mu.Unlock()
	slog.Info("started", "service", spec.Name, "pid", cmd.Process.Pid)

	if spec.ReadyURL != "" {
		if err := m.awaitReady(ctx, p); err != nil {
			m.Stop(p)
			return nil, err
		}
	}
	return p, nil
}

// Stop sends SIGTERM to p's process group, escalating to SIGKILL.
func (m *Manager) Stop(p *Process) {
	if p == nil {
		return
	}
	m.untrack(p)
	p.stopOnce.Do(func() {
		if p.Cmd == nil || p.Cmd.Process == nil {
			return
		}
		p.prefix.Flush()
		TerminateProcess(p.Cmd.Process.Pid, p.Done, p.spec.StopTimeout)
		p.Cmd = nil
	})
}

// ForceKillProcess sends SIGKILL immediately to p's process group, bypassing
// the graceful SIGTERM step. Use when the parent is force-exiting and any
// lingering child would otherwise be orphaned in its own process group.
func (m *Manager) ForceKillProcess(p *Process) {
	if p == nil {
		return
	}
	m.untrack(p)
	p.stopOnce.Do(func() {
		if p.Cmd == nil || p.Cmd.Process == nil {
			return
		}
		p.prefix.Flush()
		ForceKillProcess(p.Cmd.Process.Pid)
		p.Cmd = nil
	})
}

// ForceKillAll SIGKILLs every tracked process immediately, in parallel. It is
// the "second Ctrl+C" path — children in their own process groups survive
// plain os.Exit, so an explicit kill is required to avoid orphaning them.
func (m *Manager) ForceKillAll() {
	m.mu.Lock()
	procs := make([]*Process, len(m.procs))
	copy(procs, m.procs)
	m.procs = nil
	m.mu.Unlock()
	var wg sync.WaitGroup
	for _, p := range procs {
		wg.Add(1)
		go func(p *Process) {
			defer wg.Done()
			m.ForceKillProcess(p)
		}(p)
	}
	wg.Wait()
}

func (m *Manager) untrack(target *Process) {
	m.mu.Lock()
	defer m.mu.Unlock()
	for i, process := range m.procs {
		if process != target {
			continue
		}
		copy(m.procs[i:], m.procs[i+1:])
		m.procs[len(m.procs)-1] = nil
		m.procs = m.procs[:len(m.procs)-1]
		return
	}
}

// StopAll terminates every tracked process in parallel.
func (m *Manager) StopAll() {
	m.mu.Lock()
	procs := make([]*Process, len(m.procs))
	copy(procs, m.procs)
	m.procs = nil
	m.mu.Unlock()
	var wg sync.WaitGroup
	for _, p := range procs {
		wg.Add(1)
		go func(p *Process) {
			defer wg.Done()
			m.Stop(p)
		}(p)
	}
	wg.Wait()
}

// SweepRepo scans the process table for orphaned repo-scoped dev processes
// (agentteam-server/vite/etc.) and kills them. This is a last-resort
// cleanup to use after StopAll when grandchildren may have escaped.
func (m *Manager) SweepRepo(repoRoot string) {
	sweepRepo(repoRoot)
}

// Run attaches a prefix to the manager's output and writes a single line.
func (m *Manager) Run(name string) {
	// Helper for parity with slog usage; not currently used externally.
	_ = name
}

func (m *Manager) awaitReady(ctx context.Context, p *Process) error {
	url := readyURL(p.spec.ReadyURL)
	slog.Info("waiting for ready", "service", p.Name, "url", url)
	client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{Proxy: nil}}
	maxAttempts := int(p.spec.ReadyTimeout / time.Second)
	if maxAttempts < 1 {
		maxAttempts = 1
	}
	lastProbe := "no probe attempted"
	for i := 0; i < maxAttempts; i++ {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}
		if err, exited := processExitError(p); exited {
			return fmt.Errorf("exited before ready: %w", err)
		}
		resp, err := client.Get(url)
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				if err, exited := processExitError(p); exited {
					return fmt.Errorf("exited before ready: %w", err)
				}
				slog.Info("ready", "service", p.Name)
				return nil
			}
			lastProbe = fmt.Sprintf("HTTP %s", resp.Status)
		} else {
			lastProbe = err.Error()
		}
		if (i+1)%10 == 0 {
			slog.Warn("still not ready", "service", p.Name, "elapsed", i+1, "last_probe", lastProbe)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(time.Second):
		}
	}
	return fmt.Errorf("%s not ready after %s (last probe: %s)", p.Name, p.spec.ReadyTimeout, lastProbe)
}

func processExitError(p *Process) (error, bool) {
	if p == nil || p.Done == nil {
		return nil, false
	}
	select {
	case <-p.Done:
		err := p.ExitErr()
		if err == nil {
			err = errors.New("process exited")
		}
		return err, true
	default:
		return nil, false
	}
}

func readyURL(addr string) string {
	switch {
	case strings.HasPrefix(addr, "http://"), strings.HasPrefix(addr, "https://"):
		return strings.TrimRight(addr, "/") + "/readyz"
	case strings.HasPrefix(addr, ":"):
		return "http://127.0.0.1" + addr + "/readyz"
	default:
		return "http://" + addr + "/readyz"
	}
}
