// Package lookup is the canonical seam for resolving an agent / skill
// / MCP server NAME to its effective runtime definition. It walks the
// multi-tier lookup chain (tenant-scoped dynamic → static cfg → shared
// dynamic, see Agent for the precedence) and applies the same
// normalizer chain the static boot-time config-load path applies, so a
// name resolved at runtime returns a byte-equivalent definition to one
// resolved at boot. RFC N threaded a tenant axis through the dynamic
// tiers; the default tenant "" preserves the pre-RFC-N order exactly.
//
// # Why this package exists
//
// Loomcycle's pre-v0.8 substrate-era had ONE load path:
//
//	yaml → config.LoadConfig → resolveSkills / resolveAgent → cfg.Agents
//
// v0.8.15 added `dynamic_agents` and v0.8.22 added the AgentDef
// substrate (`agent_defs` + `agent_def_active`). Both new read paths
// skipped the boot-time normalizers — symptom: PR #186 ("set
// SystemPromptBase for runtime-resolved agents") had to bolt on
// `normalizeSystemPromptBase` at the read site after agents started
// silently losing their instructions on every skill-enabled run. The
// JSON-tag mismatch in PR #184 was a separate face of the same drift:
// `config.AgentDef` carries yaml-only tags, but the substrate persists
// snake_case JSON via `mergedDef` — every field silently dropped on
// unmarshal until a `substrateAgentDef` adapter was added.
//
// This package consolidates the lookup chain + normalizer chain + the
// adapter into one place. The runtime contract: a substrate-pushed
// AgentDef MUST produce a config.AgentDef byte-equivalent to the same
// content loaded from yaml. The equivalence is verified by the test
// in agent_equivalence_test.go.
//
// # Architectural pattern for future substrates
//
// When adding a dynamic substrate for a domain type:
//
//  1. The read path MUST walk the same normalizer chain as the
//     boot-time load. If `resolveSkills` (or analogue) sets a
//     derived field for the static path, your read path must set
//     it too. Add a normalizer to this package.
//  2. The persistence shape uses explicit JSON tags. Unmarshal
//     targets a struct WITH JSON tags, then convert to the runtime
//     consumer type via a single named adapter. NEVER `json.Unmarshal`
//     into a yaml-only struct (silently no-ops; the bug PR #184
//     fixed for AgentDef).
//  3. Add an equivalence test: yaml-load vs substrate-load of the
//     same content must produce byte-equivalent runtime defs.
//  4. Add a drift test: reflection-based audit pinning every field
//     in the runtime consumer struct has a corresponding tag in the
//     persistence-shape adapter, so a future field added to one
//     without the other fails the test instead of silently dropping.
package lookup

import (
	"context"
	"encoding/json"

	"github.com/denn-gubsky/loomcycle/internal/config"
	"github.com/denn-gubsky/loomcycle/internal/store"
)

// AgentStore is the subset of store.Store the agent resolver uses.
// Declared here so tests + callers can mock without depending on the
// full store interface. RFC N: the dynamic lookups carry a tenantID.
type AgentStore interface {
	DynamicAgentGet(ctx context.Context, tenantID, name string) (store.DynamicAgent, error)
	AgentDefGetActive(ctx context.Context, tenantID, name string) (store.AgentDefRow, error)
}

// Agent resolves an agent NAME to its effective config.AgentDef within
// the caller's tenant, walking the lookup chain in precedence order:
//
//  1. (tenantID != "") tenant-scoped dynamic lookup — dynamic_agents
//     then agent_def_active, both WHERE tenant_id=tenantID.
//  2. static cfg.Agents (yaml-defined, pre-normalized at boot) — the
//     shared operator base every tenant inherits.
//  3. shared dynamic lookup (tenant_id="") — dynamic_agents then
//     agent_def_active.
//
// For the default tenant "" step 1 is skipped, so the order collapses to
// static-cfg → shared-dynamic — IDENTICAL to the pre-RFC-N behavior
// ("cfg.Agents first, then dynamic"). This is the critical back-compat
// property: a single-tenant deployment (everything tenant_id="") behaves
// exactly as before.
//
// "static = shared base, tenant-dynamic shadows it" (RFC N §Proposed
// design): a per-tenant dynamic registration shadows the shared static
// base by name; the static yaml stays a shared base every tenant
// inherits unless they override it dynamically.
//
// Returns (zero, false) when no source has the name. Malformed
// persistence JSON also returns (zero, false) — defensive against
// future-field churn or hand-edited rows.
//
// Normalization: every dynamic path applies NormalizeAgentDef before
// returning, equalizing the runtime shape with what config-load
// would have produced for the same yaml content. Static cfg.Agents
// entries already went through config-load's resolveSkills /
// resolveAgent, so the cfg path returns directly without re-
// normalizing (avoids double-baking skill bodies into SystemPrompt).
func Agent(ctx context.Context, s AgentStore, cfg *config.Config, tenantID, name string) (config.AgentDef, bool) {
	// 1. Tenant-scoped dynamic shadow (skipped for the shared "" tenant
	//    so its order stays static-cfg → shared-dynamic, exactly as
	//    pre-RFC-N).
	if tenantID != "" {
		if def, ok := resolveDynamic(ctx, s, tenantID, name); ok {
			return def, true
		}
	}
	// 2. Static cfg.Agents — the shared operator base.
	if cfg != nil {
		if def, ok := cfg.Agents[name]; ok {
			return def, true
		}
	}
	// 3. Shared dynamic (tenant_id="").
	return resolveDynamic(ctx, s, "", name)
}

// resolveDynamic runs the two dynamic tiers for one tenant pass:
//
//	dynamic_agents (RegisterAgent path) → agent_def_active + agent_defs
//	(AgentDef substrate)
//
// both scoped to tenantID. Returns (zero, false) when neither tier has
// the name for that tenant (or the store is nil). Called once for the
// tenant pass and once for the shared "" pass by Agent.
func resolveDynamic(ctx context.Context, s AgentStore, tenantID, name string) (config.AgentDef, bool) {
	if s == nil {
		return config.AgentDef{}, false
	}
	// Tier A — dynamic_agents. Persistence uses config.AgentDef's JSON
	// tags directly; safe to unmarshal into config.AgentDef.
	if row, err := s.DynamicAgentGet(ctx, tenantID, name); err == nil {
		var def config.AgentDef
		if uerr := json.Unmarshal(row.Definition, &def); uerr == nil {
			NormalizeAgentDef(&def)
			return def, true
		}
	}
	// Tier B — substrate (agent_def_active overlay → agent_defs row).
	// Persistence uses mergedDef's snake_case JSON tags; must unmarshal
	// into a json-tagged adapter then convert.
	activeRow, err := s.AgentDefGetActive(ctx, tenantID, name)
	if err != nil {
		return config.AgentDef{}, false
	}
	// A retired def must never be served, even if the active pointer still
	// references it. The retire-of-active path now clears the pointer, so
	// this is defense-in-depth for legacy/corrupt rows (ActiveRetired) — a
	// run for a retired-but-active name falls through to static/none rather
	// than silently running a retired definition.
	if activeRow.Retired {
		return config.AgentDef{}, false
	}
	var sd SubstrateAgentDef
	if uerr := json.Unmarshal(activeRow.Definition, &sd); uerr != nil {
		return config.AgentDef{}, false
	}
	def := sd.ToConfigDef()
	NormalizeAgentDef(&def)
	return def, true
}

// SubstrateAgentDef mirrors the JSON shape `AgentDef.create` persists
// in `agent_defs.definition` (snake_case via the json tags on
// `mergedDef` in internal/tools/builtin/agentdef.go). The runtime
// consumer (`config.AgentDef`) carries ONLY yaml tags — unmarshalling
// substrate JSON directly into it silently drops every field because
// json.Unmarshal then matches against Go field names instead.
//
// This adapter + ToConfigDef is the seam. Kept in sync with
// `mergedDef`; TestAgentDef_DriftDetection in drift_test.go pins the
// field set so a future field added to mergedDef without a matching
// addition here fails the build.
type SubstrateAgentDef struct {
	Provider string `json:"provider,omitempty"`
	Model    string `json:"model,omitempty"`
	// Code mirrors config.AgentDef.Code — the inline code-js body (RFC J).
	// Persisted in the agent_defs definition JSONB; "" = filesystem
	// fallback. Tag "code_body" matches mergedDef + AgentContent.
	Code   string `json:"code_body,omitempty"`
	Tier   string `json:"tier,omitempty"`
	Effort string `json:"effort,omitempty"`
	// Sampling: per-agent LLM sampling params (mirrors config.Sampling /
	// mergedDef). Pointer so substrate JSON persists nil (provider default)
	// vs an explicit temperature:0.0 (deterministic).
	Sampling *config.Sampling `json:"sampling,omitempty"`
	// Compaction: per-agent context-compaction settings (mirrors config.Compaction
	// / mergedDef).
	Compaction    *config.Compaction `json:"compaction,omitempty"`
	MaxTokens     int                `json:"max_tokens,omitempty"`
	MaxIterations int                `json:"max_iterations,omitempty"`
	// UnboundedIterations lifts the MaxIterations soft-cap for an LLM agent
	// (interactive runs). Mirrors mergedDef; the drift test pins parity.
	UnboundedIterations bool `json:"unbounded_iterations,omitempty"`
	// MaxConcurrentChildren caps how many sub-agents this agent may
	// spawn in parallel via Agent.parallel_spawn. 0 = use runtime
	// default (DefaultMaxConcurrentChildren = 4). Mirrors the
	// config.AgentDef yaml field.
	MaxConcurrentChildren int `json:"max_concurrent_children,omitempty"`
	// RunTimeoutSeconds mirrors config.AgentDef.RunTimeoutSeconds — the
	// per-agent code-js wall-clock budget (RFC J). 0 = global default.
	RunTimeoutSeconds int    `json:"run_timeout_seconds,omitempty"`
	SystemPrompt      string `json:"system_prompt,omitempty"`
	// SystemPromptBase carries the pre-skill-bake snapshot when the
	// substrate write path (commit 3 of this PR) persisted it.
	// Read-side normalizers fall back to SystemPrompt when this is
	// empty (legacy rows that pre-date the write-side fix).
	SystemPromptBase string   `json:"system_prompt_base,omitempty"`
	Tools            []string `json:"tools,omitempty"`
	Skills           []string `json:"skills,omitempty"`
	Providers        []string `json:"providers,omitempty"`
	// SearchProviders mirrors config.AgentDef.SearchProviders (RFC BB) so a
	// runtime-authored agent's web-search fallback list survives the substrate
	// round-trip instead of being silently dropped before the run. Kept in sync
	// with builtin.mergedDef (the drift test pins it).
	SearchProviders  []string                          `json:"search_providers,omitempty"`
	Models           map[string][]config.TierCandidate `json:"models,omitempty"`
	MemoryScopes     []string                          `json:"memory_scopes,omitempty"`
	MemoryQuotaBytes int                               `json:"memory_quota_bytes,omitempty"`
	// SqlScopes / SqlQuotaBytes / HistoryScope / Volumes mirror config.AgentDef.
	// Without them a runtime-authored (or forked) agent came back DEFAULT-DENY
	// on its SQL-Memory, History and filesystem reach — the same class as the
	// F40 *_def_scopes gap. Kept in sync with builtin.mergedDef (the drift test
	// pins it).
	SqlScopes     []string `json:"sql_scopes,omitempty"`
	SqlQuotaBytes int      `json:"sql_quota_bytes,omitempty"`
	HistoryScope  []string `json:"history_scope,omitempty"`
	Volumes       []string `json:"volumes,omitempty"`
	// MemoryBackend mirrors config.AgentDef.MemoryBackend — the named
	// memory backend this agent routes through. RFC I MR-3b.
	MemoryBackend string `json:"memory_backend,omitempty"`
	// CoreBlocks / InheritCoreBlocks / MemoryInjectMaxTokens / MemoryProtocol
	// mirror config.AgentDef (RFC BL P1) so a runtime-authored agent's core-block
	// config survives the substrate round-trip instead of being dropped before
	// the run. Kept in sync with builtin.mergedDef (the drift test pins it).
	CoreBlocks            []config.CoreBlock `json:"core_blocks,omitempty"`
	InheritCoreBlocks     bool               `json:"inherit_core_blocks,omitempty"`
	MemoryInjectMaxTokens int                `json:"memory_inject_max_tokens,omitempty"`
	MemoryProtocol        bool               `json:"memory_protocol,omitempty"`
	Internal              bool               `json:"internal,omitempty"`             // maintenance plumbing; content-identifying
	MemoryConsolidation   bool               `json:"memory_consolidation,omitempty"` // RFC BL P2 grant
	MemoryIndexMaxBytes   int                `json:"memory_index_max_bytes,omitempty"`
	MemoryRoots           string             `json:"memory_roots,omitempty"`
	// RetryAttempts mirrors config.AgentDef.RetryAttempts — per-agent
	// same-provider retry budget override. *int so substrate JSON can
	// persist the operator-meaningful "force 0" intent as distinct
	// from "field not set" (use the tier default).
	RetryAttempts *int `json:"retry_attempts,omitempty"`
	// Channels / EvaluationScopes / Interruption mirror config.AgentDef so an
	// agent authored via the substrate (agentdef create/fork) round-trips a
	// COMPLETE interactive/multi-agent config, not just tool-bearing (F14).
	// Kept in sync with builtin.mergedDef (the drift test pins it).
	Channels         config.AgentChannelACL      `json:"channels,omitempty"`
	EvaluationScopes []string                    `json:"evaluation_scopes,omitempty"`
	Interruption     config.AgentInterruptionACL `json:"interruption,omitempty"`
	// F40: the *_def_scopes capability gates — the substrate-def slice of the
	// F14 closure, so a runtime-authored meta-agent's fork/schedule/etc.
	// authority survives the round-trip instead of defaulting to deny. The
	// drift test pins parity with builtin.mergedDef.
	// (The SkillDef def-scope gate was removed in RFC BA — see builtin.mergedDef;
	// the drift test pins that this struct stays in parity.)
	AgentDefScopes         []string `json:"agent_def_scopes,omitempty"`
	ScheduleDefScopes      []string `json:"schedule_def_scopes,omitempty"`
	A2AServerCardDefScopes []string `json:"a2a_server_card_def_scopes,omitempty"`
	A2AAgentDefScopes      []string `json:"a2a_agent_def_scopes,omitempty"`
	// VolumeDefScopes (RFC AH Phase 2a) — the dynamic-volume slice of the
	// F40 closure, so a runtime-authored ensemble launcher's volume
	// authority survives the round-trip instead of defaulting to deny.
	VolumeDefScopes []string `json:"volume_def_scopes,omitempty"`
}

// ToConfigDef projects the substrate JSON shape onto config.AgentDef
// for the runtime to consume. Pure data shuffling; no normalization
// happens here (NormalizeAgentDef is called afterward by Agent).
func (s SubstrateAgentDef) ToConfigDef() config.AgentDef {
	return config.AgentDef{
		Provider:              s.Provider,
		Model:                 s.Model,
		Code:                  s.Code,
		Tier:                  s.Tier,
		Effort:                s.Effort,
		Sampling:              s.Sampling,
		Compaction:            s.Compaction,
		MaxTokens:             s.MaxTokens,
		MaxIterations:         s.MaxIterations,
		UnboundedIterations:   s.UnboundedIterations,
		MaxConcurrentChildren: s.MaxConcurrentChildren,
		RunTimeoutSeconds:     s.RunTimeoutSeconds,
		SystemPrompt:          s.SystemPrompt,
		SystemPromptBase:      s.SystemPromptBase,
		Tools:                 s.Tools,
		Skills:                s.Skills,
		Providers:             s.Providers,
		SearchProviders:       s.SearchProviders,
		Models:                s.Models,
		MemoryScopes:          s.MemoryScopes,
		MemoryQuotaBytes:      s.MemoryQuotaBytes,
		MemoryBackend:         s.MemoryBackend,
		// Surface the capability gates to the policy layer, or the run resolves
		// them as empty = default-deny.
		SqlScopes:     s.SqlScopes,
		SqlQuotaBytes: s.SqlQuotaBytes,
		HistoryScope:  s.HistoryScope,
		Volumes:       s.Volumes,
		// RFC BL P1 core memory blocks.
		CoreBlocks:            s.CoreBlocks,
		InheritCoreBlocks:     s.InheritCoreBlocks,
		MemoryInjectMaxTokens: s.MemoryInjectMaxTokens,
		MemoryProtocol:        s.MemoryProtocol,
		Internal:              s.Internal,
		MemoryConsolidation:   s.MemoryConsolidation,
		MemoryIndexMaxBytes:   s.MemoryIndexMaxBytes,
		MemoryRoots:           s.MemoryRoots,
		RetryAttempts:         s.RetryAttempts,
		Channels:              s.Channels,
		EvaluationScopes:      s.EvaluationScopes,
		Interruption:          s.Interruption,
		// F40: surface the capability gates to the policy layer so a
		// runtime-authored meta-agent's AgentDef/Schedule/etc. scopes apply.
		AgentDefScopes:         s.AgentDefScopes,
		ScheduleDefScopes:      s.ScheduleDefScopes,
		A2AServerCardDefScopes: s.A2AServerCardDefScopes,
		A2AAgentDefScopes:      s.A2AAgentDefScopes,
		VolumeDefScopes:        s.VolumeDefScopes,
	}
}

// NormalizeAgentDef applies the boot-time normalization chain that
// `config.LoadConfig` / `resolveSkills` applied to statically-yaml-
// defined agents. Called by Agent() on every dynamic-load path before
// returning, so substrate-resolved agents reach the runtime with the
// same effective shape as yaml-resolved ones.
//
// Steps (each is idempotent under the static cfg path — re-applying
// them to a static cfg.AgentDef is a no-op):
//
//  1. SystemPromptBase invariant — fills SystemPromptBase from
//     SystemPrompt when empty. `resolveSkills` sets this at boot for
//     any yaml agent that lists skills, capturing the pre-skill-bake
//     snapshot for resolveSkillBodiesForRun to rebuild from. Agents
//     resolved at runtime never go through resolveSkills, so without
//     this normalization the rebuild starts from "" and concatenates
//     the skill body — silently replacing the agent's instructions.
//     See PR #186 for the production bug + the symptom.
//
// Future normalizers go here. Each MUST be idempotent under the
// static cfg path (so re-applying to an already-normalized agent is a
// no-op) and MUST mirror exactly what the equivalent boot-time helper
// does for yaml-defined agents.
func NormalizeAgentDef(def *config.AgentDef) {
	if def.SystemPromptBase == "" {
		def.SystemPromptBase = def.SystemPrompt
	}
}
