// Package orchestrator owns the task FSM, reapers, and policy enforcement.
//
// Task state machine (plan §12):
//
//	created → assigned → in_progress
//	                       ├→ awaiting_handoff
//	                       ├→ awaiting_clarification
//	                       ├→ awaiting_subtask
//	                       ├→ awaiting_draft_signoff
//	                       └→ awaiting_steering_ack
//	                  → completed
//	                  → failed → (requeued | escalated | abandoned)
//
// Every state transition writes one event row.
package orchestrator

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"strings"
	"time"

	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/event"
	"github.com/flothus/tmux-xterm-research/server-go/internal/harness/store"
)

// TaskState enumerates the legal task states.
type TaskState string

const (
	StateCreated              TaskState = "created"
	StateAssigned             TaskState = "assigned"
	StateInProgress           TaskState = "in_progress"
	StateAwaitingHandoff      TaskState = "awaiting_handoff"
	StateAwaitingClarif       TaskState = "awaiting_clarification"
	StateAwaitingSubtask      TaskState = "awaiting_subtask"
	StateAwaitingDraftSignoff TaskState = "awaiting_draft_signoff"
	StateAwaitingSteeringAck  TaskState = "awaiting_steering_ack"
	StateCompleted            TaskState = "completed"
	StateFailed               TaskState = "failed"
	StateRequeued             TaskState = "requeued"
	StateEscalated            TaskState = "escalated"
	StateAbandoned            TaskState = "abandoned"
)

// legalTransitions defines the FSM. A transition not in this map is rejected.
// Escalation is reachable from every non-terminal state — the harness must be
// able to escalate at any moment (thrash, policy, manual).
// `StateAbandoned` is reachable from every non-terminal state so the
// run-end cleanup can mark in-flight tasks as abandoned-not-failed when a
// run is killed/timed-out. Abandoned means "the work was interrupted by
// the run ending, not by an intrinsic task failure" — this is the
// distinction that lets the dashboard and grader stop conflating
// timed-out-but-working with actually-broken.
var legalTransitions = map[TaskState]map[TaskState]struct{}{
	StateCreated:              {StateAssigned: {}, StateAbandoned: {}, StateFailed: {}, StateEscalated: {}},
	StateAssigned:             {StateInProgress: {}, StateAbandoned: {}, StateFailed: {}, StateEscalated: {}},
	StateInProgress:           {StateAwaitingHandoff: {}, StateAwaitingClarif: {}, StateAwaitingSubtask: {}, StateAwaitingDraftSignoff: {}, StateAwaitingSteeringAck: {}, StateCompleted: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateAwaitingHandoff:      {StateAssigned: {}, StateInProgress: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateAwaitingClarif:       {StateInProgress: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateAwaitingSubtask:      {StateInProgress: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateAwaitingDraftSignoff: {StateInProgress: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateAwaitingSteeringAck:  {StateInProgress: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
	StateFailed:               {StateRequeued: {}, StateEscalated: {}, StateAbandoned: {}},
	StateRequeued:             {StateAssigned: {}, StateFailed: {}, StateAbandoned: {}, StateEscalated: {}},
}

// Task is the row shape. Body lives at body_path on disk.
type Task struct {
	ID            string
	RunID         string
	ParentTaskID  string
	OwnerAgentID  string
	PromptID      string
	Title         string
	BodyPath      string
	State         TaskState
	Deadline      *time.Time
	Attempts      int
	CreatedAt     time.Time
	UpdatedAt     time.Time
}

// Orchestrator wires together the store and event bus.
type Orchestrator struct {
	St  *store.Store
	Bus *event.Bus
}

// New returns an Orchestrator.
func New(st *store.Store, bus *event.Bus) *Orchestrator {
	return &Orchestrator{St: st, Bus: bus}
}

// CreateRun inserts a runs row and returns the id.
func (o *Orchestrator) CreateRun(ctx context.Context, runID, userReq string) error {
	now := store.Now()
	err := o.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(
			`INSERT INTO runs(id, started_at, user_request, status) VALUES(?, ?, ?, 'running')`,
			runID, store.FmtTime(now), nullable(userReq),
		)
		return err
	})
	if err != nil {
		return err
	}
	_, _ = o.Bus.Emit(ctx, event.Event{Kind: event.KindRunStarted, RunID: runID})
	return nil
}

// RunStatus is the closed set of legal terminal+nonterminal run statuses,
// enforced by the v4/v5/v6 schema triggers. Adding a new value here requires
// a new migration to teach the trigger about it.
type RunStatus string

const (
	RunRunning      RunStatus = "running"
	RunCompleted    RunStatus = "completed"
	RunKilled       RunStatus = "killed"       // externally stopped (ceiling/ctx/stalled)
	RunFailed       RunStatus = "failed"       // root task ended in failure
	RunUnrouted     RunStatus = "unrouted"     // master could not dispatch; nothing was tried
	RunAwaitingUser RunStatus = "awaiting_user" // master is blocked on clarification from the user
)

// FailureCategory taxonomy. Stored in runs.failure_category. The empty
// string is reserved for the success case (status='completed') — every
// terminal-failure status requires a non-empty category, enforced by the
// v6 triggers.
type FailureCategory string

const (
	FailNone         FailureCategory = ""
	FailUnrouted     FailureCategory = "unrouted"
	FailStalled      FailureCategory = "stalled"
	FailMaxTurns     FailureCategory = "max_turns"
	FailMaxWall      FailureCategory = "max_wall"
	FailCtxCanceled  FailureCategory = "ctx_canceled"
	FailSpawnFailed  FailureCategory = "spawn_failed"
	FailTaskFailed   FailureCategory = "task_failed"
	FailCostCeiling  FailureCategory = "cost_ceiling"
	FailManual       FailureCategory = "manual"
)

// RunOutcome is the full description of why a run ended. The status field
// decides which schema invariants apply (e.g. status=completed requires
// root_task_id and a completed root task).
type RunOutcome struct {
	Status          RunStatus
	FailureCategory FailureCategory // must be FailNone iff Status==RunCompleted
	FailureDetail   string          // free-text, e.g. an underlying error message
}

// EndRun ends a run with the given outcome. First-write-wins: only the
// initial caller sets ended_at/status/failure_category; subsequent calls
// are ignored (logged as a no-op event) so the watchdog and the main poll
// loop can both race without overwriting causal information.
//
// The UPDATE uses `WHERE id=? AND status='running'` so the row is only
// modified once. The "did we actually update" check (RowsAffected==1) is
// what makes the race deterministic.
func (o *Orchestrator) EndRun(ctx context.Context, runID string, outcome RunOutcome) error {
	if outcome.Status == "" {
		return fmt.Errorf("orchestrator: EndRun requires Status")
	}
	if outcome.Status == RunCompleted && outcome.FailureCategory != FailNone {
		return fmt.Errorf("orchestrator: EndRun completed forbids FailureCategory")
	}
	if outcome.Status != RunCompleted && outcome.Status != RunRunning && outcome.Status != RunAwaitingUser && outcome.FailureCategory == FailNone {
		return fmt.Errorf("orchestrator: EndRun status=%s requires FailureCategory", outcome.Status)
	}

	now := store.Now()
	var firstWriter bool
	var failedTasks []string
	// V101: EndRun must succeed even when the caller's ctx is canceled —
	// the canonical kill case IS ctx-canceled. Use a fallback short-
	// timeout context so the row is guaranteed to reach a terminal
	// status; without this the run would stay 'running' forever.
	txCtx := ctx
	if ctx.Err() != nil {
		bg, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		txCtx = bg
	}
	err := o.St.Tx(txCtx, func(q store.Querier) error {
		res, err := q.Exec(
			`UPDATE runs SET ended_at=?, status=?, failure_category=?, failure_detail=?, kill_reason=?
			 WHERE id=? AND status='running'`,
			store.FmtTime(now), string(outcome.Status), string(outcome.FailureCategory),
			outcome.FailureDetail, nullable(outcome.FailureDetail), runID,
		)
		if err != nil {
			return err
		}
		n, _ := res.RowsAffected()
		firstWriter = n == 1
		if !firstWriter {
			return nil
		}
		// V18 + abandoned/failed split: no terminal-status run may leave
		// tasks dangling in non-terminal states. Every in-flight task at
		// run-end transitions to `abandoned` — including when the run is
		// declared completed via an early-end path (e.g. the introspection
		// refusal-fallback that closes the root task before the worker
		// chain naturally completes).
		//
		// `abandoned` (not `failed`) is the right label because the work
		// wasn't broken — it was interrupted by run-end. `failed` is
		// reserved for real per-task errors (parse failure, tool error,
		// policy violation) which carry a diagnostic payload via the agent
		// runtime. Without this split, dashboards conflate timed-out-but-
		// working tasks with actually-broken ones.
		{
			rows, err := q.Query(
				`SELECT id FROM tasks
				   WHERE run_id=?
				     AND state NOT IN ('completed','failed','abandoned','escalated')`,
				runID,
			)
			if err != nil {
				return err
			}
			for rows.Next() {
				var id string
				if err := rows.Scan(&id); err != nil {
					rows.Close()
					return err
				}
				failedTasks = append(failedTasks, id)
			}
			rows.Close()
			reason := "run_ended:" + string(outcome.FailureCategory)
			if outcome.Status == RunCompleted {
				// Completed-with-early-end carries no failure_category,
				// so use a distinct reason label so consumers can tell
				// "abandoned because run was killed" apart from
				// "abandoned because the run-end declared the work done
				// before the subchain caught up."
				reason = "run_completed_early"
			}
			for _, tid := range failedTasks {
				if _, err := q.Exec(
					`UPDATE tasks SET state='abandoned', updated_at=? WHERE id=?`,
					store.FmtTime(now), tid,
				); err != nil {
					return err
				}
				// In-tx event log writes so the abandoned-task transitions
				// are atomic with the run's own terminal write.
				if _, err := o.Bus.EmitInTx(q, event.Event{
					Kind: event.KindTaskStateChanged, RunID: runID, TaskID: tid,
					Payload: map[string]any{
						"from": "open", "to": "abandoned", "reason": reason,
					},
				}); err != nil {
					return err
				}
				if _, err := o.Bus.EmitInTx(q, event.Event{
					Kind: event.KindTaskAbandoned, RunID: runID, TaskID: tid,
					Payload: map[string]any{"reason": reason},
				}); err != nil {
					return err
				}
			}
		}
		return nil
	})
	if err != nil {
		return err
	}
	// V101: terminal audit events use context.Background() because the
	// common kill case IS ctx-canceled. If we used the caller's ctx, the
	// event would never get persisted — the DB would reach 'killed' with
	// no audit trail of why. Background ctx guarantees the emit lands.
	emitCtx := context.Background()
	if !firstWriter {
		// Lost the race. Don't emit run.ended/run.killed again.
		_, _ = o.Bus.Emit(emitCtx, event.Event{
			Kind:    "run.end_noop", RunID: runID,
			Payload: map[string]any{"attempted_status": string(outcome.Status), "attempted_category": string(outcome.FailureCategory)},
		})
		return nil
	}
	kind := event.KindRunEnded
	if outcome.Status != RunCompleted {
		kind = event.KindRunKilled
	}
	_, _ = o.Bus.Emit(emitCtx, event.Event{
		Kind:  kind,
		RunID: runID,
		Payload: map[string]any{
			"status":           string(outcome.Status),
			"category":         string(outcome.FailureCategory),
			"detail":           outcome.FailureDetail,
			"failed_task_ids":  failedTasks,
			"failed_task_count": len(failedTasks),
			// Backwards-compat: keep the legacy field name in the payload so
			// dashboards/tests reading "reason" don't go dark in one step.
			"reason": outcome.FailureDetail,
		},
	})
	return nil
}

// SetRunRoot atomically sets runs.root_task_id IF it is still NULL. Returns
// true if this call was the one that set it. The first delegated task is the
// root by definition; subsequent calls are no-ops. The v5 trigger validates
// that the referenced task exists, so the caller must have already inserted
// the task before invoking this.
func (o *Orchestrator) SetRunRoot(ctx context.Context, runID, taskID string) (bool, error) {
	var won bool
	err := o.St.Tx(ctx, func(q store.Querier) error {
		res, err := q.Exec(
			`UPDATE runs SET root_task_id=? WHERE id=? AND root_task_id IS NULL`,
			taskID, runID,
		)
		if err != nil {
			return err
		}
		n, _ := res.RowsAffected()
		won = n == 1
		return nil
	})
	return won, err
}

// Notification is one user-facing notification row.
type Notification struct {
	ID          int64
	RunID       string
	TaskID      string
	Kind        string
	Payload     string
	Delivered   bool
	CreatedAt   string
	DeliveredAt string
}

// ListNotifications returns user-facing notifications for a run, ordered
// newest-first. If onlyPending is true, returns only undelivered ones.
//
// V60: user_notifications rows are written by the master but nothing read
// them; the user had no surface to see refusals or clarification questions.
// This is the programmatic read entry point CLI / HTTP / dashboard layers
// can build on.
func (o *Orchestrator) ListNotifications(ctx context.Context, runID string, onlyPending bool) ([]Notification, error) {
	query := `SELECT id, IFNULL(run_id,''), IFNULL(task_id,''), kind, payload, delivered, created_at, IFNULL(delivered_at,'')
	            FROM user_notifications
	           WHERE (?='' OR run_id=?)`
	if onlyPending {
		query += ` AND delivered=0`
	}
	query += ` ORDER BY id DESC`
	rows, err := o.St.DB().QueryContext(ctx, query, runID, runID)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Notification
	for rows.Next() {
		var n Notification
		var delivered int
		if err := rows.Scan(&n.ID, &n.RunID, &n.TaskID, &n.Kind, &n.Payload, &delivered, &n.CreatedAt, &n.DeliveredAt); err != nil {
			return nil, err
		}
		n.Delivered = delivered == 1
		out = append(out, n)
	}
	return out, rows.Err()
}

// MarkNotificationDelivered flips a notification's delivered flag. Idempotent.
// Used by UI/CLI surfaces after they've displayed the notification to the user.
func (o *Orchestrator) MarkNotificationDelivered(ctx context.Context, notifID int64) error {
	return o.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(
			`UPDATE user_notifications SET delivered=1, delivered_at=? WHERE id=? AND delivered=0`,
			store.FmtTime(store.Now()), notifID,
		)
		return err
	})
}

// MarkAgentTerminated atomically updates the agents row to status='terminated'
// (idempotent — already-terminal rows are left alone) and emits the canonical
// agent.terminated event. Used by the run-cleanup path and any other code
// that needs the DB to reflect a real-world termination.
//
// First-write-wins: the UPDATE only fires when status is non-terminal, so
// concurrent callers won't race to overwrite the original terminated_at.
func (o *Orchestrator) MarkAgentTerminated(ctx context.Context, agentID, reason string) error {
	now := store.Now()
	var runID string
	var won bool
	err := o.St.Tx(ctx, func(q store.Querier) error {
		// Capture run_id for the event, and only update if still non-terminal.
		_ = q.QueryRow(`SELECT IFNULL(run_id,'') FROM agents WHERE id=?`, agentID).Scan(&runID)
		res, err := q.Exec(
			`UPDATE agents SET status='terminated', terminated_at=?
			   WHERE id=? AND status NOT IN ('terminated','failed','cleaned_up')`,
			store.FmtTime(now), agentID,
		)
		if err != nil {
			return err
		}
		n, _ := res.RowsAffected()
		won = n == 1
		return nil
	})
	if err != nil {
		return err
	}
	if won {
		_, _ = o.Bus.Emit(ctx, event.Event{
			Kind: event.KindAgentTerminated, RunID: runID, AgentID: agentID,
			Payload: map[string]any{"reason": reason},
		})
	}
	return nil
}

// CreateTask inserts a task in state=created.
func (o *Orchestrator) CreateTask(ctx context.Context, t Task) error {
	if t.ID == "" {
		return errors.New("orchestrator: task id required")
	}
	now := store.Now()
	if t.State == "" {
		t.State = StateCreated
	}
	err := o.St.Tx(ctx, func(q store.Querier) error {
		var deadline any
		if t.Deadline != nil {
			deadline = store.FmtTime(*t.Deadline)
		}
		_, err := q.Exec(
			`INSERT INTO tasks(id, run_id, parent_task_id, owner_agent_id, prompt_id, title, body_path,
				state, deadline, attempts, created_at, updated_at)
			 VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
			t.ID, t.RunID, nullable(t.ParentTaskID), nullable(t.OwnerAgentID),
			nullable(t.PromptID), t.Title, nullable(t.BodyPath),
			string(t.State), deadline, t.Attempts, store.FmtTime(now), store.FmtTime(now),
		)
		return err
	})
	if err != nil {
		return err
	}
	_, _ = o.Bus.Emit(ctx, event.Event{
		Kind: event.KindTaskCreated, RunID: t.RunID, TaskID: t.ID,
		Payload: map[string]any{"title": t.Title, "parent": t.ParentTaskID},
	})
	return nil
}

// Transition applies an FSM transition or rejects it. The state UPDATE and
// the resulting event rows are written inside the same transaction — the
// "every transition writes one event row" invariant becomes structural
// (a crash between UPDATE and event INSERT used to leave the row in a new
// state with no event log; that window is now closed).
func (o *Orchestrator) Transition(ctx context.Context, taskID string, to TaskState) error {
	now := store.Now()
	var from TaskState
	var runID string
	var emits []event.Event
	err := o.St.Tx(ctx, func(q store.Querier) error {
		row := q.QueryRow(`SELECT state, run_id FROM tasks WHERE id=?`, taskID)
		var s, r string
		if err := row.Scan(&s, &r); err != nil {
			return err
		}
		from = TaskState(s)
		runID = r
		allowed, ok := legalTransitions[from]
		if !ok {
			return fmt.Errorf("orchestrator: terminal state %q has no transitions", from)
		}
		if _, ok := allowed[to]; !ok {
			return fmt.Errorf("orchestrator: illegal transition %s → %s", from, to)
		}
		if _, err := q.Exec(`UPDATE tasks SET state=?, updated_at=? WHERE id=?`, string(to), store.FmtTime(now), taskID); err != nil {
			return err
		}
		// In-tx event log writes — atomic with the state UPDATE.
		stateChanged := event.Event{
			Kind: event.KindTaskStateChanged, RunID: runID, TaskID: taskID,
			Payload: map[string]any{"from": string(from), "to": string(to)},
		}
		if _, err := o.Bus.EmitInTx(q, stateChanged); err != nil {
			return err
		}
		emits = append(emits, stateChanged)
		if to == StateCompleted {
			ev := event.Event{Kind: event.KindTaskCompleted, RunID: runID, TaskID: taskID}
			if _, err := o.Bus.EmitInTx(q, ev); err != nil {
				return err
			}
			emits = append(emits, ev)
		} else if to == StateAbandoned {
			ev := event.Event{Kind: event.KindTaskAbandoned, RunID: runID, TaskID: taskID}
			if _, err := o.Bus.EmitInTx(q, ev); err != nil {
				return err
			}
			emits = append(emits, ev)
		} else if to == StateFailed {
			ev := event.Event{Kind: event.KindTaskFailed, RunID: runID, TaskID: taskID}
			if _, err := o.Bus.EmitInTx(q, ev); err != nil {
				return err
			}
			emits = append(emits, ev)
		}
		return nil
	})
	if err != nil {
		return err
	}
	// Fan-out to in-process subscribers after commit so they never observe
	// a row the tx didn't actually persist.
	for _, ev := range emits {
		o.Bus.Fan(ev)
	}
	return nil
}

// AssignOwner sets the owner agent on a task and, if the task is currently
// in 'created', transitions it to 'assigned' — all inside one transaction.
//
// The previous implementation did the SELECT outside the tx, then called
// Transition (its own tx), then Emit (its own tx). That was three races:
//   - TOCTOU on the state SELECT (could change between read and Transition)
//   - Owner UPDATE could commit while Transition failed silently (the
//     return value was discarded with `_ =`)
//   - The owner UPDATE + transition + event were three independent commits
//
// We now do everything in one tx and propagate any FSM error to the caller.
func (o *Orchestrator) AssignOwner(ctx context.Context, taskID, agentID string) error {
	now := store.Now()
	var runID string
	var transitioned bool
	err := o.St.Tx(ctx, func(q store.Querier) error {
		row := q.QueryRow(`SELECT run_id, state FROM tasks WHERE id=?`, taskID)
		var curState string
		if err := row.Scan(&runID, &curState); err != nil {
			return err
		}
		if _, err := q.Exec(`UPDATE tasks SET owner_agent_id=?, updated_at=? WHERE id=?`, agentID, store.FmtTime(now), taskID); err != nil {
			return err
		}
		// FSM walk: only created → assigned is legal from a freshly created
		// task; from any other starting state we leave the FSM alone (this
		// is an owner-only reassignment, not a state change).
		if TaskState(curState) == StateCreated {
			if _, err := q.Exec(`UPDATE tasks SET state='assigned', updated_at=? WHERE id=?`, store.FmtTime(now), taskID); err != nil {
				return err
			}
			if _, err := o.Bus.EmitInTx(q, event.Event{
				Kind: event.KindTaskStateChanged, RunID: runID, TaskID: taskID,
				Payload: map[string]any{"from": string(StateCreated), "to": string(StateAssigned)},
			}); err != nil {
				return err
			}
			transitioned = true
		}
		// task.assigned event is part of the same atomic write.
		_, err := o.Bus.EmitInTx(q, event.Event{
			Kind: event.KindTaskAssigned, RunID: runID, TaskID: taskID, AgentID: agentID,
		})
		return err
	})
	if err != nil {
		return err
	}
	// Fan to subscribers after commit.
	if transitioned {
		o.Bus.Fan(event.Event{
			Kind: event.KindTaskStateChanged, RunID: runID, TaskID: taskID,
			Payload: map[string]any{"from": string(StateCreated), "to": string(StateAssigned)},
		})
	}
	o.Bus.Fan(event.Event{Kind: event.KindTaskAssigned, RunID: runID, TaskID: taskID, AgentID: agentID})
	return nil
}

// SetDeadline updates a task deadline (used when an agent picks up a long
// task and wants to extend its time).
//
// V98: past-date deadlines are rejected. Setting deadline=past would cause
// ReapStalledTasks to immediately fail the task — could be a buggy or
// malicious agent trying to fail its own task. Allow a 5s grace for clock
// skew between the agent and the harness clock.
func (o *Orchestrator) SetDeadline(ctx context.Context, taskID string, deadline time.Time) error {
	if deadline.Before(store.Now().Add(-5 * time.Second)) {
		return fmt.Errorf("orchestrator: deadline %s is in the past", deadline.Format(time.RFC3339))
	}
	return o.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(`UPDATE tasks SET deadline=?, updated_at=? WHERE id=?`,
			store.FmtTime(deadline), store.FmtTime(store.Now()), taskID,
		)
		return err
	})
}

// nullable converts an empty string to a nil for SQL NULL.
func nullable(s string) any {
	if s == "" {
		return nil
	}
	return s
}

// scanTask is a helper used by reapers and tests.
func scanTask(row *sql.Row) (Task, error) {
	var t Task
	var parent, owner, prompt, body, deadline sql.NullString
	var createdAt, updatedAt string
	var state string
	if err := row.Scan(&t.ID, &t.RunID, &parent, &owner, &prompt, &t.Title, &body, &state, &deadline, &t.Attempts, &createdAt, &updatedAt); err != nil {
		return Task{}, err
	}
	t.ParentTaskID = parent.String
	t.OwnerAgentID = owner.String
	t.PromptID = prompt.String
	t.BodyPath = body.String
	t.State = TaskState(state)
	if deadline.Valid {
		if d, err := time.Parse("2006-01-02T15:04:05.000Z", strings.TrimSpace(deadline.String)); err == nil {
			t.Deadline = &d
		}
	}
	t.CreatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", createdAt)
	t.UpdatedAt, _ = time.Parse("2006-01-02T15:04:05.000Z", updatedAt)
	return t, nil
}

// IsLegalTransition reports whether from→to is in the FSM.
func IsLegalTransition(from, to TaskState) bool {
	allowed, ok := legalTransitions[from]
	if !ok {
		return false
	}
	_, ok = allowed[to]
	return ok
}
