// Package agentruntime adapts the daemon/Hub Agent runtime overlay into the
// Workflow callback recovery working-state reader. It is the production bridge
// between the real Agent turn/execution state (which drives Web Agent Activity)
// and the RecoveryRunner's authoritative working judgment. It deliberately lives in
// its own package so both the production server (internal/server) and the e2e
// server (cmd/loop-e2e-server) can construct it without an import cycle.
package agentruntime

import (
	"context"
	"strings"
	"time"

	"github.com/tidbcloud/AgentTeam/internal/store/agent"
	"github.com/tidbcloud/AgentTeam/internal/store/workflow/run"
)

// Agent turn execution states reported by the daemon via the Hub overlay. These
// mirror desktop/internal/types.AgentTurnState* on the wire and are the
// authoritative recovery working signal values.
const (
	AgentTurnStateActive = "active"
	AgentTurnStateQueued = "queued"
	AgentTurnStateIdle   = "idle"
)

// MachineResolver resolves the durable machine that owns an agent, so the
// runtime working reader can query the daemon/Hub overlay for that machine.
type MachineResolver interface {
	MachineIDForAgent(context.Context, string, string) (string, error)
}

// runtimeOverlay is the narrow slice of the daemon/Hub Agent runtime overlay
// the recovery working reader needs. *daemon.Hub implements it.
type runtimeOverlay interface {
	MachineConnected(machineID string) bool
	// MachineConnectedAt returns when the current daemon connection established.
	// ok=false means the machine is not connected. The timestamp is anchored once
	// per connection and never moves, so it is the stable grace anchor that
	// polling, duplicate ready, restart, and old-epoch races cannot extend.
	MachineConnectedAt(machineID string) (time.Time, bool)
	// AgentRuntimeTurnState returns the daemon-derived real Agent turn execution
	// state (active/queued/idle) and whether it is known. This is the ONLY
	// liveness signal the recovery working reader consumes for a known state.
	AgentRuntimeTurnState(machineID string, agentID string) (string, bool)
	// ObserveAgentRuntime samples the structured, immutable runtime observation
	// for one Agent responder on a connected machine: the current connection/
	// session generation plus the per-Agent turn-state revision/watermark and
	// state as one observation. The caller MUST hold the machine observation
	// read lease (AcquireObservationGuard) while the observation is in use
	// across the recovery transition.
	ObserveAgentRuntime(machineID string, agentID string) (run.RecoveryObservation, bool)
	// AcquireObservationGuard takes the machine observation read lease and
	// returns a release that MUST be called after the row-locked RecoveryTick
	// transaction commits (and on every path). While held, no authoritative
	// turn-state replacement for the machine can commit. ok=false means the
	// machine is not connected and no lease is held.
	AcquireObservationGuard(machineID string) (release func(), ok bool)
}

// defaultTurnStateSnapshotGrace bounds how long an online responder may lack a
// current-epoch turn-state snapshot before recovery treats it as recoverable
// (idle). It is deliberately shorter than the first recovery interval (1 minute)
// so a missing snapshot cannot stall recovery forever.
const defaultTurnStateSnapshotGrace = 60 * time.Second

// HubWorkingReaderOptions configures the clock and bounded grace used to decide
// when an online responder missing a current snapshot becomes recoverable.
type HubWorkingReaderOptions struct {
	// Now is the clock used for grace decisions. Defaults to time.Now.
	Now func() time.Time
	// Grace is the bounded window after connection during which an online
	// responder with no known turn state is deferred (never woken, no recovery
	// timing). After it elapses the responder is recoverable (treated as idle).
	// Defaults to defaultTurnStateSnapshotGrace. A zero or negative Grace means
	// recoverable immediately (no deferral).
	Grace time.Duration
}

// HubWorkingReader is the production authoritative Agent runtime working reader for
// Workflow callback recovery. It queries the daemon-derived real Agent turn
// execution state (active/queued/idle) exposed by the server Hub overlay,
// resolving the frozen responder's machine first. It NEVER reads the Workflow
// capacity lease or callback FIFO, never synthesizes a TTL/heartbeat liveness,
// and never infers working from process/status/activity/session presence — a
// live idle session is idle, not working.
//
// State semantics (Leader round-4 contract + bounded-grace round for #t2596):
//   - known=false, machine offline: the daemon is not connected (e.g. not
//     reconnected after a server/worker restart). The recovery tick must NOT send
//     and must NOT start recovery timing; recovery defers and resumes once the
//     machine reconnects and a snapshot is established.
//   - known=false, machine online, within grace: the machine reconnected but no
//     current-epoch snapshot (or per-agent turn state) has arrived yet. Deferred
//     (no send, no timing) for a bounded grace only — polling, duplicate ready,
//     restart, or races cannot extend it, because the grace is anchored to the
//     connection timestamp.
//   - known=true, recoverable: the machine is online and either the responder is
//     idle (snapshot present) or the bounded grace has elapsed with no snapshot.
//     Recovery advances the durable schedule.
//   - working=true: a real active or queued turn is executing (not recoverable).
type HubWorkingReader struct {
	machines MachineResolver
	hub      runtimeOverlay
	now      func() time.Time
	grace    time.Duration
}

// NewHubWorkingReader constructs the reader with the default clock and grace. A
// nil machine resolver or nil hub degrades every query to unknown (known=false)
// so production never falls back to a recovery send when the runtime state
// cannot be observed.
func NewHubWorkingReader(machines MachineResolver, hub runtimeOverlay) *HubWorkingReader {
	return NewHubWorkingReaderWithOptions(machines, hub, HubWorkingReaderOptions{})
}

// NewHubWorkingReaderWithOptions constructs the reader with an explicit clock and
// bounded grace window.
func NewHubWorkingReaderWithOptions(machines MachineResolver, hub runtimeOverlay, options HubWorkingReaderOptions) *HubWorkingReader {
	now := options.Now
	if now == nil {
		now = time.Now
	}
	grace := options.Grace
	if grace == 0 {
		grace = defaultTurnStateSnapshotGrace
	}
	return &HubWorkingReader{machines: machines, hub: hub, now: now, grace: grace}
}

// AcquireRuntimeObservation resolves the responder's machine, acquires the
// machine observation read lease, and samples the immutable structured runtime
// observation (including the derived working/known judgment). It implements
// orchestrator.RuntimeObservationReader: the caller MUST call the returned release
// after the row-locked RecoveryTick transaction commits (and on every error
// path). ok=false means the observation could not be acquired (unknown/offline,
// machine not connected, or a resolver failure) and no lease is held.
//
// Lock order: machine resolution (a DB read) happens BEFORE the guard is
// acquired, never while holding it; the guard is then held until the caller
// releases it after commit. No machine-resolution DB read ever runs while the
// Hub guard is held, and no DB->Hub lock inversion is possible.
func (reader *HubWorkingReader) AcquireRuntimeObservation(ctx context.Context, workspaceID, agentID string) (run.RecoveryObservation, func(), bool) {
	workspaceID, agentID = strings.TrimSpace(workspaceID), strings.TrimSpace(agentID)
	if reader == nil || reader.machines == nil || reader.hub == nil || workspaceID == "" || agentID == "" {
		return run.RecoveryObservation{}, nil, false
	}
	// 1. Resolve the durable machine BEFORE acquiring any Hub guard.
	machineID, err := reader.machines.MachineIDForAgent(ctx, workspaceID, agentID)
	if err != nil || strings.TrimSpace(machineID) == "" {
		return run.RecoveryObservation{}, nil, false
	}
	machineID = strings.TrimSpace(machineID)
	// 2. Acquire the machine observation read lease.
	release, ok := reader.hub.AcquireObservationGuard(machineID)
	if !ok {
		return run.RecoveryObservation{}, nil, false
	}
	// 3. Sample the immutable observation under the lease.
	obs, ok := reader.hub.ObserveAgentRuntime(machineID, agentID)
	if !ok {
		release()
		return run.RecoveryObservation{}, nil, false
	}
	// 4. Derive the working/known judgment (bounded snapshot grace included).
	obs.Working, obs.Known, obs.Reason = reader.observationJudgment(obs)
	return obs, release, true
}

// observationJudgment derives the recovery working/known decision from an
// immutable observation. It preserves the fail-safe semantics:
//   - not connected: unknown (offline), never recoverable from this observation.
//   - connected with a real active/queued turn: working.
//   - connected with a real idle turn: recoverable (idle).
//   - connected but no current turn state: deferred within the bounded snapshot
//     grace anchored to the connection, recoverable (idle) after it elapses.
func (reader *HubWorkingReader) observationJudgment(obs run.RecoveryObservation) (working bool, known bool, reason string) {
	if !obs.Connected {
		return false, false, "offline"
	}
	if obs.StateKnown {
		switch obs.State {
		case AgentTurnStateActive, AgentTurnStateQueued:
			return true, true, "working"
		case AgentTurnStateIdle:
			return false, true, "idle"
		default:
			// Any unrecognized value is treated as unknown (fail-safe). Fall
			// through to the bounded-grace path.
		}
	}
	if reader.observationGraceElapsed(obs) {
		return false, true, "idle"
	}
	return false, false, "grace"
}

// observationGraceElapsed reports whether the current connection's bounded
// snapshot grace has elapsed, anchored to the observation's connection
// timestamp so polling, duplicate ready, restart, and old-epoch races cannot
// extend it. A missing anchor resolves to elapsed (recoverable) so an empty Hub
// can never stall recovery.
func (reader *HubWorkingReader) observationGraceElapsed(obs run.RecoveryObservation) bool {
	if reader.grace <= 0 {
		return true
	}
	if obs.ConnectedAt.IsZero() {
		return true
	}
	return reader.now().Sub(obs.ConnectedAt) > reader.grace
}

// RuntimeWorking implements orchestrator.RuntimeWorkingReader.
func (reader *HubWorkingReader) RuntimeWorking(ctx context.Context, workspaceID, agentID string) (working bool, known bool) {
	working, known, _ = reader.runtimeWorkingDetail(ctx, workspaceID, agentID)
	return working, known
}

// RuntimeWorkingReason returns a compact diagnostic reason for the recovery
// judgment of one responder: "working" (active/queued), "idle" (recoverable),
// "offline" (machine not connected, deferred), "grace" (online but no current
// snapshot, within the bounded window), or "unknown" (state cannot be
// determined). It is best-effort observability and never gates the judgment.
func (reader *HubWorkingReader) RuntimeWorkingReason(ctx context.Context, workspaceID, agentID string) string {
	_, _, reason := reader.runtimeWorkingDetail(ctx, workspaceID, agentID)
	return reason
}

func (reader *HubWorkingReader) runtimeWorkingDetail(ctx context.Context, workspaceID, agentID string) (working bool, known bool, reason string) {
	workspaceID, agentID = strings.TrimSpace(workspaceID), strings.TrimSpace(agentID)
	if reader == nil || reader.machines == nil || reader.hub == nil || workspaceID == "" || agentID == "" {
		return false, false, "unknown"
	}
	machineID, err := reader.machines.MachineIDForAgent(ctx, workspaceID, agentID)
	if err != nil || strings.TrimSpace(machineID) == "" {
		// Cannot resolve the responder's machine: fail-safe unknown.
		return false, false, "unknown"
	}
	machineID = strings.TrimSpace(machineID)
	if !reader.hub.MachineConnected(machineID) {
		// The daemon is not connected (e.g. not reconnected after a server/worker
		// restart). The current state cannot be determined and recovery defers;
		// it resumes once the daemon reconnects and reports a snapshot.
		return false, false, "offline"
	}
	state, ok := reader.hub.AgentRuntimeTurnState(machineID, agentID)
	if ok {
		switch state {
		case AgentTurnStateActive, AgentTurnStateQueued:
			// A real executing or queued turn: working, not recoverable.
			return true, true, "working"
		case AgentTurnStateIdle:
			// Live session but no active/queued turn: idle, recoverable.
			return false, true, "idle"
		default:
			// Any unrecognized value is treated as unknown (fail-safe). Fall
			// through to the bounded-grace path below.
		}
	}
	// The machine is online but the responder's turn state is not known (no
	// current-epoch snapshot yet, the responder is absent from the snapshot, or
	// the daemon cannot report it). Give the connection a bounded grace before the
	// responder becomes recoverable so recovery can never stall on an empty Hub.
	if reader.graceElapsed(machineID) {
		return false, true, "idle"
	}
	// Within the bounded grace: defer (no send, no recovery timing).
	return false, false, "grace"
}

// graceElapsed reports whether the current connection's bounded snapshot grace
// has elapsed. It is anchored to MachineConnectedAt, which is set once per
// connection and never moved by ready re-application or incremental events, so
// polling, duplicate ready, restart, and old-epoch races cannot extend grace
// forever. A missing connection resolves to elapsed (recoverable) because the
// caller has already verified the machine is connected.
func (reader *HubWorkingReader) graceElapsed(machineID string) bool {
	connectedAt, ok := reader.hub.MachineConnectedAt(machineID)
	if !ok {
		return true
	}
	if reader.grace <= 0 {
		return true
	}
	if connectedAt.IsZero() {
		// No connection anchor recorded (defensive): fail toward recoverable so an
		// empty Hub can never stall recovery.
		return true
	}
	return reader.now().Sub(connectedAt) > reader.grace
}

var _ interface {
	RuntimeWorking(context.Context, string, string) (bool, bool)
} = (*HubWorkingReader)(nil)

// ServiceAdapter adapts the concrete agent store service to the narrow
// MachineResolver interface.
type ServiceAdapter struct {
	Service *agent.Service
}

// MachineIDForAgent implements MachineResolver.
func (adapter ServiceAdapter) MachineIDForAgent(ctx context.Context, workspaceID, agentID string) (string, error) {
	if adapter.Service == nil {
		return "", nil
	}
	return adapter.Service.MachineIDForAgent(ctx, workspaceID, agentID)
}
