// Phase J: org-builder.
//
// The org-builder agent reads recent runs, looks at evaluator scores +
// coordination metrics, and proposes a structural diff against a baseline
// org. The proposal is a new yaml file in the proposal directory + a row in
// `proposals`. The proposal is then evaluated by the falsifier (§17 J done-
// criterion).
//
// The "diff" the builder produces is intentionally mechanical: it adjusts
// policy knobs based on observed pathologies. The plan calls the *what to
// propose* an open research question (§16); the *pipeline* — read runs,
// emit a proposal, gate by falsifier, decide promote-or-reject — is what
// Phase J implements.
package orchestrator

import (
	"context"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"time"

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

// Proposal is a structured org-diff proposal.
type Proposal struct {
	ID              string
	BaselineOrgID   string
	BaselineVersion int
	ProposedPath    string // yaml file with the candidate org
	Rationale       string
	CreatedAt       time.Time
	Status          string // "pending" | "accepted" | "rejected"
	FalsifierJSON   string // serialized FalsifierResult once evaluated
}

// OrgBuilder reads runs and proposes org diffs.
type OrgBuilder struct {
	St      *store.Store
	Bus     *event.Bus
	OrgsDir string // where to write proposed-*.yaml
}

// NewOrgBuilder constructs an OrgBuilder.
func NewOrgBuilder(st *store.Store, bus *event.Bus, orgsDir string) *OrgBuilder {
	return &OrgBuilder{St: st, Bus: bus, OrgsDir: orgsDir}
}

// Propose reads recent runs of the baseline org, derives a structural change
// based on observed pathologies, and writes a proposed yaml. Returns the
// Proposal record (also persisted via storeProposal).
//
// Heuristics implemented for Phase J:
//   - If any run had ≥1 coordination.thrash event AND
//     baseline.thrash_max_exchanges > 2, propose lowering by 1.
//   - Else if baseline.max_fanout < 6 AND mean tasks_completed >= 2,
//     propose raising max_fanout by 1.
//   - Else if baseline.max_depth > 2 AND no escalations were observed,
//     propose lowering max_depth by 1 (shallower hierarchy).
//
// These are mechanical proposals (not LLM-generated). The point is that the
// pipeline is wired: read → reason → propose → falsifier-gate. The choice of
// what to propose is what an LLM-backed org-builder will eventually do; the
// mechanical rules here serve as a deterministic test harness for the gate.
func (b *OrgBuilder) Propose(ctx context.Context, baselineOrgPath string) (*Proposal, error) {
	baseline, err := org.Load(baselineOrgPath)
	if err != nil {
		return nil, fmt.Errorf("orgbuilder: load baseline: %w", err)
	}
	// Aggregate stats across runs attributed to this org.
	rows, err := b.St.DB().QueryContext(ctx,
		`SELECT id FROM runs WHERE org_id=?`, baseline.Name,
	)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var runIDs []string
	for rows.Next() {
		var id string
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		runIDs = append(runIDs, id)
	}
	stats := struct {
		Thrashes        int
		EscalatedTasks  int
		CompletedTotal  int
		Runs            int
	}{}
	for _, id := range runIDs {
		var t int
		_ = b.St.DB().QueryRowContext(ctx,
			`SELECT COUNT(*) FROM events WHERE run_id=? AND kind='coordination.thrash'`, id).Scan(&t)
		stats.Thrashes += t
		var esc, comp int
		_ = b.St.DB().QueryRowContext(ctx,
			`SELECT COUNT(*) FROM tasks WHERE run_id=? AND state='escalated'`, id).Scan(&esc)
		_ = b.St.DB().QueryRowContext(ctx,
			`SELECT COUNT(*) FROM tasks WHERE run_id=? AND state='completed'`, id).Scan(&comp)
		stats.EscalatedTasks += esc
		stats.CompletedTotal += comp
		stats.Runs++
	}

	// Decide on a change.
	change, rationale := b.deriveChange(baseline, stats)
	if change == nil {
		return nil, errors.New("orgbuilder: no proposal — baseline already at lowest-risk policy values")
	}

	// Apply change to a copy of the yaml. For Phase J we do a simple
	// line-level rewrite of the policies block.
	rawBytes, err := os.ReadFile(baselineOrgPath)
	if err != nil {
		return nil, err
	}
	rewritten := change.Apply(string(rawBytes))
	if rewritten == "" {
		return nil, errors.New("orgbuilder: change applied empty result")
	}

	if err := os.MkdirAll(b.OrgsDir, 0o755); err != nil {
		return nil, err
	}
	propID := fmt.Sprintf("prop_%d", store.Now().UnixNano())
	propPath := filepath.Join(b.OrgsDir, propID+".yaml")
	if err := os.WriteFile(propPath, []byte(rewritten), 0o644); err != nil {
		return nil, err
	}

	p := &Proposal{
		ID:              propID,
		BaselineOrgID:   baseline.Name,
		BaselineVersion: baseline.Version,
		ProposedPath:    propPath,
		Rationale:       rationale,
		CreatedAt:       store.Now(),
		Status:          "pending",
	}
	if err := b.storeProposal(ctx, p); err != nil {
		return nil, err
	}
	_, _ = b.Bus.Emit(ctx, event.Event{
		Kind:    "org.proposal.created",
		Payload: map[string]any{"id": propID, "baseline": baseline.Name, "rationale": rationale},
	})
	return p, nil
}

// EvaluateProposal runs the falsifier against held-out benchmark scores.
// `baseline*` is the baseline org's measured scores; `candidate*` is the
// proposal's measured scores. The scores are *held out* — they were not
// available when Propose ran. The caller is responsible for ensuring
// this; the function just applies the criteria.
func (b *OrgBuilder) EvaluateProposal(
	ctx context.Context,
	prop *Proposal,
	crit FalsifierCriteria,
	baselinePrimary, candidatePrimary float64,
	baselineSecondary, candidateSecondary map[string]float64,
	sampleRunsPerBenchmark int,
	evaluatorAgreement float64,
) (FalsifierResult, error) {
	res, err := EvaluateDiff(crit, baselinePrimary, candidatePrimary,
		baselineSecondary, candidateSecondary,
		sampleRunsPerBenchmark, evaluatorAgreement,
	)
	if err != nil {
		return res, err
	}
	status := "rejected"
	if res.Pass {
		status = "accepted"
	}
	prop.Status = status
	err = b.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(`UPDATE proposals SET status=?, falsifier_rationale=? WHERE id=?`,
			status, res.Rationale, prop.ID,
		)
		return err
	})
	if err != nil {
		return res, err
	}
	kind := event.Kind("org.proposal.accepted")
	if !res.Pass {
		kind = event.Kind("org.proposal.rejected")
	}
	_, _ = b.Bus.Emit(ctx, event.Event{
		Kind:    kind,
		Payload: map[string]any{"id": prop.ID, "rationale": res.Rationale},
	})
	return res, nil
}

// storeProposal persists a Proposal row. We create the proposals table
// lazily (Phase A schema didn't anticipate it; in production this would be
// a new migration).
func (b *OrgBuilder) storeProposal(ctx context.Context, p *Proposal) error {
	if err := b.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(`CREATE TABLE IF NOT EXISTS proposals (
			id TEXT PRIMARY KEY,
			baseline_org_id TEXT,
			baseline_version INTEGER,
			proposed_path TEXT,
			rationale TEXT,
			created_at TEXT,
			status TEXT,
			falsifier_rationale TEXT
		)`)
		return err
	}); err != nil {
		return err
	}
	return b.St.Tx(ctx, func(q store.Querier) error {
		_, err := q.Exec(
			`INSERT INTO proposals(id, baseline_org_id, baseline_version, proposed_path, rationale, created_at, status)
			 VALUES(?, ?, ?, ?, ?, ?, ?)`,
			p.ID, p.BaselineOrgID, p.BaselineVersion, p.ProposedPath, p.Rationale,
			store.FmtTime(p.CreatedAt), p.Status,
		)
		return err
	})
}

// PolicyChange is a single proposed change to a baseline org's policies.
type PolicyChange struct {
	Key     string
	OldVal  string
	NewVal  string
}

// Apply rewrites the input yaml by replacing the policy key's value. Simple
// line-level rewrite — sufficient for the mechanical changes Phase J's
// builder makes.
func (c PolicyChange) Apply(yaml string) string {
	out := ""
	found := false
	prefix := "  " + c.Key + ":"
	for _, line := range splitLines(yaml) {
		if !found && hasPrefix(line, prefix) {
			out += prefix + " " + c.NewVal + "\n"
			found = true
			continue
		}
		out += line + "\n"
	}
	if !found {
		return ""
	}
	return out
}

func (b *OrgBuilder) deriveChange(baseline *org.Definition, stats struct {
	Thrashes        int
	EscalatedTasks  int
	CompletedTotal  int
	Runs            int
}) (*PolicyChange, string) {
	if stats.Thrashes >= 1 && baseline.Policies.ThrashMaxExchanges > 2 {
		newV := baseline.Policies.ThrashMaxExchanges - 1
		return &PolicyChange{Key: "thrash_max_exchanges",
				OldVal: fmt.Sprint(baseline.Policies.ThrashMaxExchanges),
				NewVal: fmt.Sprint(newV),
			},
			fmt.Sprintf("observed %d thrash events; lowering thrash_max_exchanges %d→%d to escalate sooner",
				stats.Thrashes, baseline.Policies.ThrashMaxExchanges, newV)
	}
	if baseline.Policies.MaxFanout < 6 && stats.Runs > 0 && stats.CompletedTotal/stats.Runs >= 2 {
		newV := baseline.Policies.MaxFanout + 1
		return &PolicyChange{Key: "max_fanout",
				OldVal: fmt.Sprint(baseline.Policies.MaxFanout),
				NewVal: fmt.Sprint(newV),
			},
			fmt.Sprintf("runs complete %d tasks/run on average; raising max_fanout %d→%d",
				stats.CompletedTotal/stats.Runs, baseline.Policies.MaxFanout, newV)
	}
	if baseline.Policies.MaxDepth > 2 && stats.EscalatedTasks == 0 {
		newV := baseline.Policies.MaxDepth - 1
		return &PolicyChange{Key: "max_depth",
				OldVal: fmt.Sprint(baseline.Policies.MaxDepth),
				NewVal: fmt.Sprint(newV),
			},
			fmt.Sprintf("no escalations observed; lowering max_depth %d→%d for shallower hierarchy",
				baseline.Policies.MaxDepth, newV)
	}
	return nil, ""
}

// PromoteTeamTemplate marks a team template as promoted (battle-tested) so
// the spawner can use it as a default starting point. We store the
// promotion as an "accepted" entry in teams_templates by raising its score.
func (b *OrgBuilder) PromoteTeamTemplate(ctx context.Context, templateID string, score float64) error {
	return b.St.Tx(ctx, func(q store.Querier) error {
		res, err := q.Exec(`UPDATE teams_templates SET score=? WHERE id=?`, score, templateID)
		if err != nil {
			return err
		}
		n, _ := res.RowsAffected()
		if n == 0 {
			return errors.New("orgbuilder: template not found")
		}
		return nil
	})
}

// --- tiny string helpers (avoid pulling strings just for two functions) ---

func splitLines(s string) []string {
	var out []string
	start := 0
	for i := 0; i < len(s); i++ {
		if s[i] == '\n' {
			out = append(out, s[start:i])
			start = i + 1
		}
	}
	if start < len(s) {
		out = append(out, s[start:])
	}
	return out
}

func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
