// Package loop implements the 'micro loop' command, which scaffolds and
// verifies an autonomous improvement loop for a repository.
//
// The loop is a set of GitHub Actions workflows that dispatch a coding agent by
// @mention on a fresh tracking issue each run. It has up to five roles:
//
//	planner    keeps a ranked queue in .github/loop/PRIORITIES.md
//	builder    builds the top open item as a single-concern PR (auto-merged on green CI)
//	triage     turns CI failures into scoped fix issues back into the queue
//	coherence  keeps README/docs/CHANGELOG aligned with the North Star (opt-in)
//	release    cuts the next patch tag when the branch has new commits (opt-in)
//
// The workflows are the MECHANISM; each dispatch role's instruction lives in an
// editable .github/loop/prompts/<role>.md file — the POLICY. That split is what
// lets any repo (including go-micro itself) customize behavior by editing prompt
// files rather than forking the CLI. `micro loop init` writes it all; `micro
// loop verify` checks the wiring.
package loop

import (
	"bytes"
	"embed"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"text/template"

	"github.com/urfave/cli/v2"
	"go-micro.dev/v6/cmd"
)

//go:embed templates/*
var templatesFS embed.FS

// config is the substitution surface for the templates — the whole config-vs-core
// boundary. The workflows and prompts are the reusable core; these are what a
// given repo tunes.
type config struct {
	// Shared.
	DefaultBranch   string // base branch for the loop's PRs (e.g. main)
	AgentMention    string // how the workflows summon the agent (e.g. @codex)
	TokenSecret     string // repo secret holding the user PAT that drives dispatch
	CIWorkflow      string // human-readable CI workflow name(s) triage watches
	CIWorkflowsYAML string // the same as a YAML array literal, e.g. ["Lint", "Run Tests"]

	// Per-dispatch-role (set while rendering each one).
	Role         string
	WorkflowName string
	IssueTitle   string
	Group        string
	Cron         string

	// Release role.
	TagPrefix   string // tag prefix to match/bump, e.g. "v"
	ReleaseCron string
}

// dispatchRole is a cron-driven role rendered from templates/dispatch.yml.tmpl.
type dispatchRole struct {
	workflowName string
	issueTitle   string
	group        string
	cronFlag     string
	defaultCron  string
}

var dispatchRoles = map[string]dispatchRole{
	"planner":   {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
	"builder":   {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
	"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
	"security":  {"Loop: Security", "Loop: security review", "loop-security", "security-cron", "0 6 * * 1"},
}

// allRoles is the full set, in a stable order, for --roles=all and help text.
var allRoles = []string{"planner", "builder", "triage", "coherence", "security", "release"}

const (
	promptDir = ".github/loop/prompts"
	loopDir   = ".github/loop"
	wfDir     = ".github/workflows"
)

func init() {
	cmd.Register(&cli.Command{
		Name:  "loop",
		Usage: "Scaffold an autonomous improvement loop for a repository",
		Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
dispatch a coding agent to plan, build, triage, and (optionally) keep docs
coherent and cut releases — gated by CI.

Roles (choose with --roles, default: planner,builder,triage):
  planner    keeps a ranked queue in .github/loop/PRIORITIES.md
  builder    builds the top open item as a single-concern PR (auto-merged on green CI)
  triage     turns CI failures into scoped fix issues back into the queue
  coherence  keeps README/docs/CHANGELOG aligned with the North Star
  security   audits for vulnerabilities and files them (fixes stay human-reviewed)
  release    cuts the next patch tag when the branch has new commits

Each dispatch role's instruction is an editable file in .github/loop/prompts/ —
edit those to steer behavior. Direction lives in .github/loop/NORTH_STAR.md.

Examples:
  # Scaffold the default loop (planner, builder, triage)
  micro loop init

  # The full loop, all five roles
  micro loop init --roles all

  # Customize the agent, token secret, base branch, and CI workflow name
  micro loop init --agent @codex --token-secret LOOP_TOKEN \
    --branch main --ci-workflow CI

  # Check that a repo is wired correctly
  micro loop verify`,
		Subcommands: []*cli.Command{
			{
				Name:  "init",
				Usage: "Scaffold the loop workflows, prompts, and queue into a repo",
				Flags: []cli.Flag{
					&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
					&cli.StringFlag{Name: "roles", Usage: "Comma-separated roles, or 'all'", Value: "planner,builder,triage"},
					&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
					&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent — any @mention-driven coding agent (e.g. @codex, @claude)", Value: "@codex"},
					&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
					&cli.StringFlag{Name: "ci-workflow", Usage: "CI workflow name(s) triage watches for failures (comma-separated)", Value: "CI"},
					&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
					&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
					&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
					&cli.StringFlag{Name: "security-cron", Usage: "Cron schedule for the security role", Value: "0 6 * * 1"},
					&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
					&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
					&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
				},
				Action: runInit,
			},
			{
				Name:   "verify",
				Usage:  "Verify a repo is wired for the loop",
				Flags:  []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
				Action: runVerify,
			},
		},
	})
}

func runInit(c *cli.Context) error {
	dir := c.String("dir")
	roles, err := parseRoles(c.String("roles"))
	if err != nil {
		return err
	}

	ciNames := splitCSV(c.String("ci-workflow"))
	cfg := config{
		DefaultBranch:   c.String("branch"),
		AgentMention:    strings.TrimSpace(c.String("agent")),
		TokenSecret:     strings.TrimSpace(c.String("token-secret")),
		CIWorkflow:      strings.Join(ciNames, ", "),
		CIWorkflowsYAML: yamlStringArray(ciNames),
		TagPrefix:       c.String("tag-prefix"),
		ReleaseCron:     c.String("release-cron"),
	}
	if cfg.DefaultBranch == "" {
		cfg.DefaultBranch = detectDefaultBranch(dir)
	}
	if !strings.HasPrefix(cfg.AgentMention, "@") {
		cfg.AgentMention = "@" + cfg.AgentMention
	}

	crons := map[string]string{
		"planner":   c.String("planner-cron"),
		"builder":   c.String("builder-cron"),
		"coherence": c.String("coherence-cron"),
		"security":  c.String("security-cron"),
	}

	if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
		return err
	}
	printNextSteps(cfg, roles)
	return nil
}

// parseRoles resolves the --roles flag into a validated, stable-ordered set.
func parseRoles(spec string) ([]string, error) {
	if strings.TrimSpace(spec) == "all" {
		return append([]string(nil), allRoles...), nil
	}
	want := map[string]bool{}
	for _, r := range strings.Split(spec, ",") {
		r = strings.TrimSpace(r)
		if r == "" {
			continue
		}
		if !isRole(r) {
			return nil, fmt.Errorf("unknown role %q (valid: %s, or 'all')", r, strings.Join(allRoles, ", "))
		}
		want[r] = true
	}
	if len(want) == 0 {
		return nil, fmt.Errorf("no roles selected")
	}
	var out []string
	for _, r := range allRoles { // preserve canonical order
		if want[r] {
			out = append(out, r)
		}
	}
	return out, nil
}

// splitCSV splits a comma-separated flag into trimmed, non-empty values.
func splitCSV(s string) []string {
	var out []string
	for _, v := range strings.Split(s, ",") {
		if v = strings.TrimSpace(v); v != "" {
			out = append(out, v)
		}
	}
	if len(out) == 0 {
		out = []string{"CI"}
	}
	return out
}

// yamlStringArray renders names as a YAML/JSON flow array, e.g. ["Lint", "Run Tests"].
// Names are known workflow display names (no embedded quotes), so a simple quote is safe.
func yamlStringArray(names []string) string {
	quoted := make([]string, len(names))
	for i, n := range names {
		quoted[i] = fmt.Sprintf("%q", n)
	}
	return "[" + strings.Join(quoted, ", ") + "]"
}

func isRole(r string) bool {
	for _, x := range allRoles {
		if x == r {
			return true
		}
	}
	return false
}

// scaffold renders the selected roles into dir. The split is deliberate:
//   - Workflows are the MECHANISM — regenerated, and overwritten with --force.
//   - Prompts, NORTH_STAR, and PRIORITIES are the POLICY — written once and
//     never clobbered, even with --force, so re-running init to refresh the
//     workflow mechanics can't wipe curated instructions, direction, or queue.
func scaffold(dir string, cfg config, roles []string, crons map[string]string, force bool) error {
	for _, role := range roles {
		switch role {
		case "triage":
			if err := renderTo(dir, "templates/loop-triage.yml.tmpl", filepath.Join(wfDir, "loop-triage.yml"), cfg, force); err != nil {
				return err
			}
			if err := renderKeep(dir, "templates/prompts/triage.md.tmpl", filepath.Join(promptDir, "triage.md"), cfg); err != nil {
				return err
			}
		case "release":
			if err := renderTo(dir, "templates/loop-release.yml.tmpl", filepath.Join(wfDir, "loop-release.yml"), cfg, force); err != nil {
				return err
			}
		default: // dispatch roles
			d := dispatchRoles[role]
			rc := cfg
			rc.Role = role
			rc.WorkflowName = d.workflowName
			rc.IssueTitle = d.issueTitle
			rc.Group = d.group
			rc.Cron = crons[role]
			if rc.Cron == "" {
				rc.Cron = d.defaultCron
			}
			if err := renderTo(dir, "templates/dispatch.yml.tmpl", filepath.Join(wfDir, "loop-"+role+".yml"), rc, force); err != nil {
				return err
			}
			if err := renderKeep(dir, "templates/prompts/"+role+".md.tmpl", filepath.Join(promptDir, role+".md"), cfg); err != nil {
				return err
			}
		}
	}

	// Direction + queue: policy, written once, never clobbered.
	if err := renderKeep(dir, "templates/NORTH_STAR.md", filepath.Join(loopDir, "NORTH_STAR.md"), cfg); err != nil {
		return err
	}
	return renderKeep(dir, "templates/PRIORITIES.md", filepath.Join(loopDir, "PRIORITIES.md"), cfg)
}

// renderTo renders a template with cfg and writes it to dir/dest (honoring force).
func renderTo(dir, tmplName, dest string, cfg config, force bool) error {
	rendered, err := render(tmplName, cfg)
	if err != nil {
		return err
	}
	if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
		return err
	}
	fmt.Printf("  wrote %s\n", dest)
	return nil
}

// renderKeep writes dir/dest only if it does not already exist — used for
// policy files (prompts, North Star, queue) so re-running init never clobbers
// customizations, regardless of --force.
func renderKeep(dir, tmplName, dest string, cfg config) error {
	full := filepath.Join(dir, dest)
	if fileExists(full) {
		fmt.Printf("  kept  %s (already exists)\n", dest)
		return nil
	}
	rendered, err := render(tmplName, cfg)
	if err != nil {
		return err
	}
	if err := writeFile(full, rendered, true); err != nil {
		return err
	}
	fmt.Printf("  wrote %s\n", dest)
	return nil
}

// verifyState reports what's wrong with dir's loop setup: warnings are
// non-fatal, missing are required files that aren't present.
func verifyState(dir string) (warnings, missing []string) {
	// A loop needs direction, a queue, and at least one role workflow.
	for _, dest := range []string{filepath.Join(loopDir, "NORTH_STAR.md"), filepath.Join(loopDir, "PRIORITIES.md")} {
		if !fileExists(filepath.Join(dir, dest)) {
			missing = append(missing, dest)
		}
	}

	present := presentLoopWorkflows(dir)
	if len(present) == 0 {
		missing = append(missing, wfDir+"/loop-*.yml (no role workflows found)")
	}

	// Every dispatch/triage role workflow needs its prompt file. (release has none.)
	for _, role := range present {
		if role == "release" {
			continue
		}
		prompt := filepath.Join(promptDir, role+".md")
		if !fileExists(filepath.Join(dir, prompt)) {
			missing = append(missing, prompt+" (prompt for the loop-"+role+" workflow)")
		}
	}

	// The loop is only as good as its gate.
	if !hasCIWorkflow(dir) {
		warnings = append(warnings, "no non-loop workflow found in "+wfDir+" — the loop needs a CI gate (build/test/lint) to merge safely")
	}
	return warnings, missing
}

// presentLoopWorkflows returns the role names for which a loop-<role>.yml exists.
func presentLoopWorkflows(dir string) []string {
	entries, err := os.ReadDir(filepath.Join(dir, wfDir))
	if err != nil {
		return nil
	}
	var out []string
	for _, e := range entries {
		name := e.Name()
		if !strings.HasPrefix(name, "loop-") {
			continue
		}
		role := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(name, "loop-"), ".yml"), ".yaml")
		out = append(out, role)
	}
	sort.Strings(out)
	return out
}

func runVerify(c *cli.Context) error {
	dir := c.String("dir")
	warnings, missing := verifyState(dir)

	for _, m := range missing {
		fmt.Printf("  MISSING  %s\n", m)
	}
	for _, w := range warnings {
		fmt.Printf("  WARN     %s\n", w)
	}

	if len(missing) > 0 {
		return fmt.Errorf("loop is not fully scaffolded (%d item(s) missing) — run `micro loop init`", len(missing))
	}

	fmt.Printf("  OK       loop is wired: %s\n", strings.Join(presentLoopWorkflows(dir), ", "))
	fmt.Println()
	fmt.Println("Reminders the CLI can't check:")
	fmt.Println("  • The token secret must be set in the repo (Settings → Secrets).")
	fmt.Println("  • Branch protection must require the CI checks with 0 approvals,")
	fmt.Println("    so the builder's auto-merge can land PRs on green CI.")
	if len(warnings) > 0 {
		return fmt.Errorf("%d warning(s) — see above", len(warnings))
	}
	return nil
}

func render(tmplName string, cfg config) ([]byte, error) {
	b, err := templatesFS.ReadFile(tmplName)
	if err != nil {
		return nil, err
	}
	// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
	// untouched — only << >> placeholders are substituted.
	t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
	if err != nil {
		return nil, fmt.Errorf("parse %s: %w", tmplName, err)
	}
	var buf bytes.Buffer
	if err := t.Execute(&buf, cfg); err != nil {
		return nil, fmt.Errorf("render %s: %w", tmplName, err)
	}
	return buf.Bytes(), nil
}

func writeFile(path string, content []byte, force bool) error {
	if fileExists(path) && !force {
		return fmt.Errorf("%s already exists (use --force to overwrite)", path)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, content, 0o644)
}

func fileExists(path string) bool {
	info, err := os.Stat(path)
	return err == nil && !info.IsDir()
}

// hasCIWorkflow reports whether .github/workflows holds any workflow that is
// not one of the loop's own (i.e. a plausible CI gate).
func hasCIWorkflow(dir string) bool {
	entries, err := os.ReadDir(filepath.Join(dir, wfDir))
	if err != nil {
		return false
	}
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if strings.HasPrefix(name, "loop-") {
			continue
		}
		if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
			return true
		}
	}
	return false
}

// detectDefaultBranch best-effort resolves the repo's default branch, falling
// back to "main".
func detectDefaultBranch(dir string) string {
	out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
	if err == nil {
		ref := strings.TrimSpace(string(out))
		if i := strings.LastIndex(ref, "/"); i >= 0 {
			ref = ref[i+1:]
		}
		if ref != "" {
			return ref
		}
	}
	return "main"
}

func printNextSteps(cfg config, roles []string) {
	fmt.Printf(`
Loop scaffolded (%s). Next steps (the CLI can't do these for you):

  1. Edit .github/loop/NORTH_STAR.md — the direction the loop aligns to.
     Seed .github/loop/PRIORITIES.md with a few real items.
     Tune the per-role instructions in .github/loop/prompts/ if you like.

  2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
     requests + issues write) for an account the agent (%s) responds to.
     The workflows no-op until this secret exists.

  3. Ensure a CI workflow named %q exists and that branch protection on %q
     requires its checks with 0 approving reviews — that green-CI gate is
     what lets the builder auto-merge safely.

  4. Commit these files, then trigger a run from the Actions tab.

Verify anytime with: micro loop verify
`, strings.Join(roles, ", "), cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
}
