// Package org loads, validates, and queries org yaml files. See plan §2,
// §4.3. Phase G done-criterion needs the loader (so cross-suborg work can
// be policy-checked) and the self-doc accessors (so a ConnectorAgent can
// fetch SELF.md + INTERFACE.yaml for both sides of a hop).
//
// Yaml is parsed without an external lib: we accept a very small subset of
// the format (key: value, lists with `- `) — enough for the org schema in
// §4.3. Saves a dependency for what is research code.
package org

import (
	"errors"
	"fmt"
	"os"
	"strings"
)

// Definition is the structured org config.
//
// DispatchMode (yaml: `dispatch_mode`) governs the user-prompt entry path:
//   - "" or "llm" (default): shovel the prompt into master's inbox; the
//     master AGENT's LLM decides what to do with it (query team, delegate,
//     synthesize). The harness only provides plumbing + primitives.
//   - "deterministic": route via the hardcoded classifier→switch→fanOut
//     workflow runner in internal/harness/deterministic/. Useful for
//     reproducible benchmark runs where you want guaranteed routing
//     regardless of model behaviour.
//
// New orgs should leave this empty (LLM mode). Existing benchmark orgs
// that depend on the workflow runner can opt back in.
type Definition struct {
	Name         string
	Version      int
	RoleSet      string
	DispatchMode string
	SubOrgs      []SubOrg
	Roles        []Role
	Teams        []Team
	Policies     Policies
	SourcePath   string
}

// SubOrg is a named subtree.
type SubOrg struct {
	ID         string
	Teams      []string
	ZoneScope  []string
}

// Role is a role declaration in the org.
//
// Overrides: by default a role's system prompt comes from the markdown
// file at Definition (a path inside the org's role_set library). For
// org-specific behaviour without copying the whole library, set
// SystemPromptPath (yaml key `system_prompt`) — relative to the org
// file's directory — and the orgrunner will load that path instead.
// Tools and Provider already act as overrides when set on the role
// (they shadow the role-set defaults), so SystemPromptPath completes
// the triple. See ResolveSystemPromptPath.
type Role struct {
	ID               string
	Definition       string
	Provider         string
	DelegatesTo      []string
	Tools            []string
	SystemPromptPath string // optional override; empty means use Definition
}

// Team is a team declaration.
type Team struct {
	ID         string
	Philosophy string
	Roster     []string
	Topology   string
	MemoryPath string
}

// Policies mirror the orchestrator.Policy struct.
type Policies struct {
	MaxDepth           int
	MaxFanout          int
	MaxTokensPerRun    int64
	MaxCostUSDPerRun   float64
	ThrashMaxExchanges int
	HandoffTimeoutMs   int
	RequeueMax         int
	CrossSubOrgConn    bool
}

// Load parses an org yaml file. Strict on required fields; lenient on shape
// variations (the writer's yaml is markdown-friendly).
func Load(path string) (*Definition, error) {
	raw, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	d, err := parse(string(raw))
	if err != nil {
		return nil, fmt.Errorf("org: %s: %w", path, err)
	}
	d.SourcePath = path
	if err := validate(d); err != nil {
		return d, err
	}
	return d, nil
}

func validate(d *Definition) error {
	if d.Name == "" {
		return errors.New("org: name required")
	}
	if d.Version <= 0 {
		return errors.New("org: version must be > 0")
	}
	// Delegate edges must reference declared roles.
	have := map[string]bool{}
	for _, r := range d.Roles {
		have[r.ID] = true
	}
	for _, r := range d.Roles {
		for _, t := range r.DelegatesTo {
			// Allow sub-org-qualified names (e.g. "frontend.fe-lead").
			id := t
			if dot := strings.Index(t, "."); dot >= 0 {
				id = t[dot+1:]
			}
			if !have[id] && !strings.HasPrefix(t, "@") {
				return fmt.Errorf("org: role %s delegates_to unknown role %q", r.ID, t)
			}
		}
	}
	// V68: detect delegation cycles statically. A cycle like
	// master → orchestrator → master would route messages forever at
	// runtime. DFS with a recursion stack: a back-edge to an ancestor
	// is a cycle. Connector references (@-prefixed) and dotted ids are
	// resolved to local role id for the purpose of cycle detection.
	graph := map[string][]string{}
	for _, r := range d.Roles {
		var children []string
		for _, t := range r.DelegatesTo {
			id := t
			if strings.HasPrefix(t, "@") {
				continue // cross-suborg connector; out of static graph
			}
			if dot := strings.Index(t, "."); dot >= 0 {
				id = t[dot+1:]
			}
			children = append(children, id)
		}
		graph[r.ID] = children
	}
	visiting := map[string]bool{}
	visited := map[string]bool{}
	var dfs func(node string, path []string) error
	dfs = func(node string, path []string) error {
		if visiting[node] {
			// Found a cycle. Build the human-readable cycle path.
			idx := -1
			for i, p := range path {
				if p == node {
					idx = i
					break
				}
			}
			cycle := append([]string{}, path[idx:]...)
			cycle = append(cycle, node)
			return fmt.Errorf("org: delegation cycle: %s", strings.Join(cycle, " → "))
		}
		if visited[node] {
			return nil
		}
		visiting[node] = true
		for _, child := range graph[node] {
			if err := dfs(child, append(path, node)); err != nil {
				return err
			}
		}
		visiting[node] = false
		visited[node] = true
		return nil
	}
	for _, r := range d.Roles {
		if err := dfs(r.ID, nil); err != nil {
			return err
		}
	}
	return nil
}

// parse is a *minimal* yaml-ish parser handling exactly the org schema in
// §4.3. Two structural rules:
//  1. Top-level `key: value` and `key:` (which starts a block).
//  2. Lists are dash-prefixed; nested dash-blocks indented by two spaces.
//
// This intentionally does not handle the whole yaml grammar; passing a
// complex yaml will produce a clear error instead of corrupt parses.
func parse(s string) (*Definition, error) {
	d := &Definition{}
	lines := strings.Split(s, "\n")
	i := 0
	for i < len(lines) {
		line := lines[i]
		trim := strings.TrimRight(line, " \t\r")
		if trim == "" || strings.HasPrefix(strings.TrimSpace(trim), "#") {
			i++
			continue
		}
		// Top-level lines have no leading whitespace.
		if line != trim || strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") {
			i++
			continue
		}
		k, v, ok := strings.Cut(trim, ":")
		if !ok {
			i++
			continue
		}
		key := strings.TrimSpace(k)
		val := strings.TrimSpace(v)
		switch key {
		case "name":
			d.Name = val
		case "version":
			fmt.Sscanf(val, "%d", &d.Version)
		case "role_set":
			d.RoleSet = val
		case "dispatch_mode":
			d.DispatchMode = val
		case "sub_orgs":
			i = parseSubOrgs(lines, i+1, &d.SubOrgs)
			continue
		case "roles":
			i = parseRoles(lines, i+1, &d.Roles)
			continue
		case "teams":
			i = parseTeams(lines, i+1, &d.Teams)
			continue
		case "policies":
			i = parsePolicies(lines, i+1, &d.Policies)
			continue
		}
		i++
	}
	return d, nil
}

func parseSubOrgs(lines []string, start int, out *[]SubOrg) int {
	i := start
	var cur *SubOrg
	for i < len(lines) {
		line := lines[i]
		if !strings.HasPrefix(line, "  ") {
			break
		}
		trim := strings.TrimSpace(line)
		if strings.HasPrefix(trim, "- ") {
			if cur != nil {
				*out = append(*out, *cur)
			}
			cur = &SubOrg{}
			rest := strings.TrimPrefix(trim, "- ")
			if k, v, ok := strings.Cut(rest, ":"); ok {
				if strings.TrimSpace(k) == "id" {
					cur.ID = strings.TrimSpace(v)
				}
			}
			i++
			continue
		}
		if cur == nil {
			i++
			continue
		}
		k, v, ok := strings.Cut(trim, ":")
		if !ok {
			i++
			continue
		}
		key := strings.TrimSpace(k)
		val := strings.TrimSpace(v)
		switch key {
		case "id":
			cur.ID = val
		case "teams":
			cur.Teams = parseInlineList(val)
		case "zone_scope":
			cur.ZoneScope = parseInlineList(val)
		}
		i++
	}
	if cur != nil {
		*out = append(*out, *cur)
	}
	return i
}

func parseRoles(lines []string, start int, out *[]Role) int {
	i := start
	var cur *Role
	for i < len(lines) {
		line := lines[i]
		if !strings.HasPrefix(line, "  ") {
			break
		}
		trim := strings.TrimSpace(line)
		if strings.HasPrefix(trim, "- ") {
			if cur != nil {
				*out = append(*out, *cur)
			}
			cur = &Role{}
			rest := strings.TrimPrefix(trim, "- ")
			if k, v, ok := strings.Cut(rest, ":"); ok {
				if strings.TrimSpace(k) == "id" {
					cur.ID = strings.TrimSpace(v)
				}
			}
			i++
			continue
		}
		if cur == nil {
			i++
			continue
		}
		k, v, ok := strings.Cut(trim, ":")
		if !ok {
			i++
			continue
		}
		key := strings.TrimSpace(k)
		val := strings.TrimSpace(v)
		switch key {
		case "id":
			cur.ID = val
		case "definition":
			cur.Definition = val
		case "provider":
			cur.Provider = val
		case "delegates_to":
			cur.DelegatesTo = parseInlineList(val)
		case "tools":
			cur.Tools = parseInlineList(val)
		case "system_prompt":
			cur.SystemPromptPath = val
		}
		i++
	}
	if cur != nil {
		*out = append(*out, *cur)
	}
	return i
}

func parseTeams(lines []string, start int, out *[]Team) int {
	i := start
	var cur *Team
	for i < len(lines) {
		line := lines[i]
		if !strings.HasPrefix(line, "  ") {
			break
		}
		trim := strings.TrimSpace(line)
		if strings.HasPrefix(trim, "- ") {
			if cur != nil {
				*out = append(*out, *cur)
			}
			cur = &Team{}
			rest := strings.TrimPrefix(trim, "- ")
			if k, v, ok := strings.Cut(rest, ":"); ok {
				if strings.TrimSpace(k) == "id" {
					cur.ID = strings.TrimSpace(v)
				}
			}
			i++
			continue
		}
		if cur == nil {
			i++
			continue
		}
		k, v, ok := strings.Cut(trim, ":")
		if !ok {
			i++
			continue
		}
		key := strings.TrimSpace(k)
		val := strings.TrimSpace(v)
		switch key {
		case "id":
			cur.ID = val
		case "philosophy":
			cur.Philosophy = strings.Trim(val, `"`)
		case "topology":
			cur.Topology = val
		case "memory":
			cur.MemoryPath = val
		case "roster":
			cur.Roster = parseInlineList(val)
		}
		i++
	}
	if cur != nil {
		*out = append(*out, *cur)
	}
	return i
}

func parsePolicies(lines []string, start int, p *Policies) int {
	i := start
	for i < len(lines) {
		line := lines[i]
		if !strings.HasPrefix(line, "  ") {
			break
		}
		trim := strings.TrimSpace(line)
		k, v, ok := strings.Cut(trim, ":")
		if !ok {
			i++
			continue
		}
		key := strings.TrimSpace(k)
		val := strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), ","))
		switch key {
		case "max_depth":
			fmt.Sscanf(val, "%d", &p.MaxDepth)
		case "max_fanout":
			fmt.Sscanf(val, "%d", &p.MaxFanout)
		case "max_tokens_per_run":
			fmt.Sscanf(strings.ReplaceAll(val, "_", ""), "%d", &p.MaxTokensPerRun)
		case "max_cost_usd_per_run":
			fmt.Sscanf(val, "%f", &p.MaxCostUSDPerRun)
		case "thrash_max_exchanges":
			fmt.Sscanf(val, "%d", &p.ThrashMaxExchanges)
		case "handoff_timeout_ms":
			fmt.Sscanf(val, "%d", &p.HandoffTimeoutMs)
		case "requeue_max":
			fmt.Sscanf(val, "%d", &p.RequeueMax)
		case "cross_suborg_requires_connector":
			p.CrossSubOrgConn = strings.EqualFold(val, "true")
		}
		i++
	}
	return i
}

// SubOrgByRole returns a roleID → subOrgID map computed from team
// rosters. Roles not assigned to any sub-org map to "" (the org root).
// V84: callers use this to enforce cross_suborg_requires_connector.
func SubOrgByRole(d *Definition) map[string]string {
	out := map[string]string{}
	if d == nil {
		return out
	}
	for _, so := range d.SubOrgs {
		for _, teamID := range so.Teams {
			var team *Team
			for i := range d.Teams {
				if d.Teams[i].ID == teamID {
					team = &d.Teams[i]
					break
				}
			}
			if team == nil {
				continue
			}
			for _, rid := range team.Roster {
				out[rid] = so.ID
			}
		}
	}
	return out
}

// IsConnectorRole reports whether a role has the `forward_with_translation`
// tool in its allowlist — the convention for cross-suborg routers. V84
// uses this to identify which roles are allowed to bridge sub-orgs even
// when `cross_suborg_requires_connector: true`.
func IsConnectorRole(role Role) bool {
	for _, t := range role.Tools {
		if t == "forward_with_translation" {
			return true
		}
	}
	return false
}

// ResolveSystemPromptPath returns the on-disk path to use for a role's
// system prompt. SystemPromptPath wins when set; otherwise Definition.
// Both are interpreted relative to the org file's directory by the
// orgrunner's existing resolveRolePath logic (V88 sandboxing).
func ResolveSystemPromptPath(role Role) string {
	if role.SystemPromptPath != "" {
		return role.SystemPromptPath
	}
	return role.Definition
}

// parseInlineList parses both `[a, b, c]` and `[a,b,c]` forms.
func parseInlineList(s string) []string {
	s = strings.TrimSpace(s)
	if s == "" || s == "[]" {
		return nil
	}
	s = strings.TrimPrefix(s, "[")
	s = strings.TrimSuffix(s, "]")
	parts := strings.Split(s, ",")
	out := make([]string, 0, len(parts))
	for _, p := range parts {
		p = strings.TrimSpace(p)
		p = strings.Trim(p, `"'`)
		if p != "" {
			out = append(out, p)
		}
	}
	return out
}
