// Package workflow is the Observer-KB workflow runner per SPEC §3.8 —
// the orchestrator that chains §3.4 modes (matrix.search, relevance,
// downgrade, distillation.score, drift.scorer) plus free-form llm.chat
// into multi-pass measurement pipelines.
//
// The architectural intent is documented in PRD's "Observer as system
// resource" section: workflows ARE observation patterns whose every
// step is recorded as an ObservedOp via observerd. The mode catalog
// is the registry of capabilities; the runner is the engine that
// composes them.
//
// First slice (this commit): types + DAG runner + reference
// substitution + a fixture.echo mode for testing the mechanics.
// Real-mode integrations (matrix.search, distillation.score, etc.)
// land in follow-up commits.
//
// YAML shape mirrors /home/profit/lakehouse/.archon/workflows/
// lakehouse-architect-review.yaml so existing Archon workflows load
// directly, with one Go-side addition: an optional `mode` field on
// each node so the runner can dispatch to non-LLM modes.

package workflow

import (
	"context"
	"errors"
	"fmt"
	"time"
)

// Workflow is one loadable workflow definition. Matches Archon's
// YAML shape; Provider + Model are informational in v0 (only used
// by llm.chat-style modes that need a backend) and ignored by other
// modes.
type Workflow struct {
	Name        string `yaml:"name" json:"name"`
	Description string `yaml:"description" json:"description"`
	Provider    string `yaml:"provider" json:"provider,omitempty"`
	Model       string `yaml:"model" json:"model,omitempty"`
	Nodes       []Node `yaml:"nodes" json:"nodes"`
}

// Node is one step in the workflow DAG. ID must be unique within a
// workflow; DependsOn lists the IDs of nodes that must complete
// before this one runs.
//
// Mode is the registered capability the node dispatches to. When
// omitted, the runner assumes "llm.chat" using the workflow's
// Provider+Model (matching Archon's implicit-LLM convention).
//
// Inputs is a free-form map passed to the mode after $-reference
// substitution. The Prompt field is a convenience — it's added to
// the input map under the key "prompt" before mode dispatch, so
// llm.chat-style modes get free-form text without a wrapping object.
type Node struct {
	ID           string         `yaml:"id" json:"id"`
	Mode         string         `yaml:"mode" json:"mode,omitempty"`
	Prompt       string         `yaml:"prompt" json:"prompt,omitempty"`
	Inputs       map[string]any `yaml:"inputs" json:"inputs,omitempty"`
	AllowedTools []string       `yaml:"allowed_tools" json:"allowed_tools,omitempty"`
	Effort       string         `yaml:"effort" json:"effort,omitempty"`
	IdleTimeoutMs int           `yaml:"idle_timeout" json:"idle_timeout,omitempty"`
	DependsOn    []string       `yaml:"depends_on" json:"depends_on,omitempty"`
}

// NodeResult captures one node's execution outcome. Output is the
// mode's return map; Error is non-nil iff the mode returned an
// error. StartedAt + DurationMs feed observerd's provenance recording.
type NodeResult struct {
	NodeID     string         `json:"node_id"`
	Mode       string         `json:"mode"`
	Output     map[string]any `json:"output,omitempty"`
	Error      string         `json:"error,omitempty"`
	StartedAt  time.Time      `json:"started_at"`
	DurationMs int64          `json:"duration_ms"`
}

// RunResult is the full workflow execution outcome — every node's
// result in execution order, plus the workflow name and a summary
// status (succeeded if every node ran without error, partial if any
// errored).
type RunResult struct {
	Workflow string       `json:"workflow"`
	Status   RunStatus    `json:"status"`
	Nodes    []NodeResult `json:"nodes"`
	StartedAt  time.Time  `json:"started_at"`
	DurationMs int64      `json:"duration_ms"`
}

// RunStatus tags the overall workflow outcome.
type RunStatus string

const (
	StatusSucceeded RunStatus = "succeeded"
	StatusPartial   RunStatus = "partial"  // some nodes errored, others succeeded
	StatusAborted   RunStatus = "aborted"  // hard error halted execution (cycle, missing dep, unknown mode)
)

// Mode is the function signature every registered capability honors.
// Input + output are generic maps so workflows compose freely; the
// mode function is responsible for shape-checking its own inputs.
//
// Returning an error doesn't abort the whole workflow — the runner
// records the error in NodeResult and continues with downstream
// nodes that don't depend on this one. That mirrors observerd's
// "log + continue" partial-failure semantics so a single mode bug
// doesn't kill a 7-node measurement chain.
type Mode func(ctx Context, input map[string]any) (map[string]any, error)

// Context is what a Mode receives. Carries the standard Go
// context.Context (for cancellation) plus a workflow-scoped
// metadata bag for cross-mode coordination (e.g. a workflow's
// model hint that llm.chat-style modes consume).
type Context struct {
	Ctx context.Context
	// WorkflowName is the parent workflow.Name — useful when a mode
	// records ObservedOps so the source can be traced back to the
	// workflow that triggered it.
	WorkflowName string
	// NodeID is the currently-executing node — paired with
	// WorkflowName forms a unique provenance key.
	NodeID string
	// Provider + Model carry the workflow's defaults; modes that
	// need them (llm.chat) pull from here, others ignore.
	Provider string
	Model    string
}

// Errors surfaced to callers. Cycle / missing-dependency / unknown-
// mode are *aborting* errors — the runner can't proceed. Per-node
// mode errors are recorded but don't abort.
var (
	ErrCycle           = errors.New("workflow: dependency cycle detected")
	ErrMissingDep      = errors.New("workflow: node depends on unknown id")
	ErrUnknownMode     = errors.New("workflow: unknown mode")
	ErrDuplicateNodeID = errors.New("workflow: duplicate node id")
	ErrUnresolvedRef   = errors.New("workflow: unresolved $node.output reference")
)

// Validate checks structural invariants on a Workflow before
// execution: unique node IDs, every depends_on points to a known
// id, no cycles. Returns nil on success or a wrapped sentinel.
func (w Workflow) Validate() error {
	if w.Name == "" {
		return fmt.Errorf("workflow: name is required")
	}
	if len(w.Nodes) == 0 {
		return fmt.Errorf("workflow: at least one node required")
	}
	seen := make(map[string]struct{}, len(w.Nodes))
	for _, n := range w.Nodes {
		if n.ID == "" {
			return fmt.Errorf("workflow: node id must be non-empty")
		}
		if _, dup := seen[n.ID]; dup {
			return fmt.Errorf("%w: %q", ErrDuplicateNodeID, n.ID)
		}
		seen[n.ID] = struct{}{}
	}
	for _, n := range w.Nodes {
		for _, dep := range n.DependsOn {
			if _, ok := seen[dep]; !ok {
				return fmt.Errorf("%w: node %q depends on %q (no such node)",
					ErrMissingDep, n.ID, dep)
			}
		}
	}
	// Cycle detection: topoSort returns a wrapped ErrCycle on any
	// cycle, including the offending node ID. Removed the separate
	// detectCycle helper after 2026-04-29 scrum2 flagged it as dead
	// code (it called topoSort then discarded the useful node ID).
	if _, err := topoSort(w.Nodes); err != nil {
		return err
	}
	return nil
}
