package codewiki

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/tidbcloud/AgentTeam/kits/agentteam-insight/internal/opencoderuntime"
)

const (
	defaultOpenCodeBinary      = "opencode"
	defaultWikiJobs            = 1
	defaultIsolatedRuntimeJobs = 4
)

var commandWaitDelay = 2 * time.Second

// Generate asks OpenCode to synthesize a detailed code wiki from source or analysis artifacts.
func Generate(ctx context.Context, options Options, runner CommandRunner) (result Result, err error) {
	if runner == nil {
		return Result{}, fmt.Errorf("command runner is required")
	}
	inputRoots, err := resolveWikiInputs(options)
	if err != nil {
		return Result{}, err
	}
	outputRoot, err := ensureDirectory("output", options.OutputRoot)
	if err != nil {
		return Result{}, err
	}

	logger := newProgressLogger(options.ProgressWriter)
	workers := normalizeWikiJobs(options.Jobs, options.IsolateOpenCodeRuntime)
	if err := validateWikiRunOptions(workers, options); err != nil {
		return Result{}, err
	}

	workRoot := filepath.Join(outputRoot, ".code-wiki")
	if err := os.MkdirAll(workRoot, 0o755); err != nil {
		return Result{}, fmt.Errorf("create wiki orchestration dir: %w", err)
	}
	workDir, err := os.MkdirTemp(workRoot, "run-*")
	if err != nil {
		return Result{}, fmt.Errorf("create wiki workdir: %w", err)
	}
	defer func() {
		if err != nil {
			logger.Printf("wiki generation failed; preserved workdir: %s", workDir)
			return
		}
		_ = os.RemoveAll(workDir)
	}()

	logger.Printf("starting wiki planning")
	plan, err := runWikiPlanningTask(ctx, workDir, inputRoots, outputRoot, options, logger, runner)
	if err != nil {
		return Result{}, err
	}
	pages := wikiPlanPages(plan)
	logger.Printf("wiki planning completed: %d pages", len(pages))

	if len(pages) > 0 && workers > len(pages) {
		workers = len(pages)
	}
	logger.Printf("running %d wiki page tasks with %d workers", len(pages), workers)
	failures, err := runWikiPageTasks(ctx, workDir, inputRoots, outputRoot, plan, pages, options, workers, logger, runner)
	if err != nil {
		return Result{}, err
	}

	result = Result{WikiDir: outputRoot, TasksRun: 1 + len(pages)}
	if len(failures) > 0 {
		logger.Printf("wiki generation completed with %d failed pages", len(failures))
		return result, wikiPageFailuresError(failures)
	}
	logger.Printf("wiki generation completed: %s", outputRoot)

	return result, nil
}

func normalizeWikiJobs(jobs int, isolateOpenCodeRuntime bool) int {
	if jobs <= 0 {
		if isolateOpenCodeRuntime {
			return defaultIsolatedRuntimeJobs
		}
		return defaultWikiJobs
	}
	return jobs
}

func validateWikiRunOptions(workers int, options Options) error {
	if workers > 1 && !options.IsolateOpenCodeRuntime {
		return fmt.Errorf("--jobs > 1 requires --isolate-opencode-runtime because parallel opencode run processes share the global opencode database")
	}
	if options.TaskTimeout < 0 {
		return fmt.Errorf("--task-timeout must be >= 0")
	}
	return nil
}

// RunCommand executes command with stdout and stderr captured separately.
func RunCommand(ctx context.Context, command Command) (CommandResult, error) {
	cmd := exec.CommandContext(ctx, command.Name, command.Args...)
	cmd.Dir = command.Dir
	cmd.WaitDelay = commandWaitDelay
	if len(command.Env) > 0 {
		cmd.Env = append(os.Environ(), command.Env...)
	}

	var stdout strings.Builder
	var stderr strings.Builder
	cmd.Stdout = commandWriter(&stdout, command.OutputWriter)
	cmd.Stderr = commandWriter(&stderr, command.OutputWriter)

	err := cmd.Run()
	if errors.Is(err, exec.ErrWaitDelay) {
		err = nil
	}
	if err != nil && ctx.Err() != nil {
		err = ctx.Err()
	}
	return CommandResult{Stdout: []byte(stdout.String()), Stderr: []byte(stderr.String())}, err
}

func commandWriter(capture io.Writer, output io.Writer) io.Writer {
	if output == nil {
		return capture
	}
	return io.MultiWriter(capture, output)
}

func resolveWikiInputs(options Options) ([]WikiInput, error) {
	roots := append([]string(nil), options.InputRoots...)
	if len(roots) == 0 && strings.TrimSpace(options.InputRoot) != "" {
		roots = append(roots, options.InputRoot)
	}
	if len(roots) == 0 {
		return nil, fmt.Errorf("input is required")
	}
	inputs := make([]WikiInput, 0, len(roots))
	usedNames := map[string]int{}
	for idx, root := range roots {
		absolute, err := requireDirectory("input", root)
		if err != nil {
			return nil, err
		}
		name := uniqueWikiInputName(wikiInputNameForRoot(absolute, idx), usedNames)
		inputs = append(inputs, WikiInput{Name: name, Root: absolute})
	}
	return inputs, nil
}

func wikiInputNameForRoot(root string, idx int) string {
	name := strings.TrimSpace(filepath.Base(root))
	name = strings.TrimSuffix(name, ".kb")
	name = strings.TrimSuffix(name, ".wiki")
	var builder strings.Builder
	lastDash := false
	for _, r := range strings.ToLower(name) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			builder.WriteRune(r)
			lastDash = false
		case r == '.', r == '_', r == '-':
			if !lastDash {
				builder.WriteByte('-')
				lastDash = true
			}
		default:
			if !lastDash {
				builder.WriteByte('-')
				lastDash = true
			}
		}
	}
	cleaned := strings.Trim(builder.String(), "-")
	if cleaned == "" {
		return fmt.Sprintf("input-%d", idx+1)
	}
	return cleaned
}

func uniqueWikiInputName(name string, used map[string]int) string {
	if used == nil {
		return name
	}
	count := used[name]
	used[name] = count + 1
	if count == 0 {
		return name
	}
	return fmt.Sprintf("%s-%d", name, count+1)
}

func runWikiPlanningTask(
	ctx context.Context,
	workRoot string,
	inputs []WikiInput,
	outputRoot string,
	options Options,
	logger *progressLogger,
	runner CommandRunner,
) (WikiPlan, error) {
	taskDir := filepath.Join(workRoot, "planner")
	if err := os.MkdirAll(taskDir, 0o755); err != nil {
		return WikiPlan{}, fmt.Errorf("create wiki planning task dir: %w", err)
	}
	if err := writeOpenCodeTaskConfig(taskDir); err != nil {
		return WikiPlan{}, err
	}
	var env []string
	if options.IsolateOpenCodeRuntime {
		var err error
		env, err = opencoderuntime.Prepare(taskDir)
		if err != nil {
			return WikiPlan{}, err
		}
	}

	command := Command{
		Dir:          taskDir,
		Name:         normalizeOpenCodeBinary(options.OpenCodeBinary),
		Args:         buildWikiPlannerArgs(options, taskDir, inputs, outputRoot),
		Env:          env,
		OutputWriter: logger,
	}
	result, err := runCommandWithTaskTimeout(ctx, options.TaskTimeout, command, runner)
	if logErr := writeCommandResultFiles(taskDir, result); logErr != nil && err == nil {
		return WikiPlan{}, logErr
	}
	if err != nil {
		return WikiPlan{}, formatCommandError(command.Name, result, err)
	}

	plan, err := readWikiPlan(filepath.Join(taskDir, "wiki_plan.json"))
	if err != nil {
		return WikiPlan{}, err
	}
	plan = normalizeWikiPlanForInputs(plan, inputs)
	if err := validateWikiPlan(plan); err != nil {
		return WikiPlan{}, err
	}
	return plan, nil
}

func runWikiPageTask(
	ctx context.Context,
	workRoot string,
	inputs []WikiInput,
	outputRoot string,
	plan WikiPlan,
	page WikiPage,
	options Options,
	logger *progressLogger,
	runner CommandRunner,
) error {
	taskDir := filepath.Join(workRoot, "pages", safeTaskID(page.ID))
	if err := os.MkdirAll(taskDir, 0o755); err != nil {
		return fmt.Errorf("create wiki page task dir: %w", err)
	}
	if err := writeOpenCodeTaskConfig(taskDir); err != nil {
		return err
	}
	outputPath := filepath.Join(outputRoot, filepath.FromSlash(page.Path))
	beforeInfo, beforeErr := os.Stat(outputPath)
	if beforeErr != nil && !os.IsNotExist(beforeErr) {
		return fmt.Errorf("stat wiki page %s existing final output %s: %w", page.ID, outputPath, beforeErr)
	}
	var env []string
	if options.IsolateOpenCodeRuntime {
		var err error
		env, err = opencoderuntime.Prepare(taskDir)
		if err != nil {
			return err
		}
	}
	command := Command{
		Dir:          taskDir,
		Name:         normalizeOpenCodeBinary(options.OpenCodeBinary),
		Args:         buildWikiPageArgs(options, taskDir, inputs, outputRoot, page),
		Env:          env,
		OutputWriter: logger,
	}
	result, err := runCommandWithTaskTimeout(ctx, options.TaskTimeout, command, runner)
	if logErr := writeCommandResultFiles(taskDir, result); logErr != nil && err == nil {
		return logErr
	}
	if err != nil {
		return formatCommandError(command.Name, result, err)
	}
	info, err := os.Stat(outputPath)
	if err != nil {
		if os.IsNotExist(err) {
			return fmt.Errorf("wiki page %s did not create final output %s", page.ID, outputPath)
		}
		return fmt.Errorf("stat wiki page %s final output %s: %w", page.ID, outputPath, err)
	}
	if info.IsDir() {
		return fmt.Errorf("wiki page %s final output is a directory: %s", page.ID, outputPath)
	}
	if beforeErr == nil && !wikiPageOutputChanged(beforeInfo, info) {
		return fmt.Errorf("wiki page %s did not update final output %s", page.ID, outputPath)
	}
	return nil
}

func wikiPageOutputChanged(before os.FileInfo, after os.FileInfo) bool {
	if before == nil || after == nil {
		return true
	}
	return !before.ModTime().Equal(after.ModTime()) || before.Size() != after.Size()
}

func writeCommandResultFiles(workDir string, result CommandResult) error {
	if len(result.Stdout) > 0 {
		if err := os.WriteFile(filepath.Join(workDir, "opencode.stdout.log"), result.Stdout, 0o644); err != nil {
			return fmt.Errorf("write opencode stdout log: %w", err)
		}
	}
	if len(result.Stderr) > 0 {
		if err := os.WriteFile(filepath.Join(workDir, "opencode.stderr.log"), result.Stderr, 0o644); err != nil {
			return fmt.Errorf("write opencode stderr log: %w", err)
		}
	}
	return nil
}

func runWikiPageTasks(
	ctx context.Context,
	workRoot string,
	inputs []WikiInput,
	outputRoot string,
	plan WikiPlan,
	pages []WikiPage,
	options Options,
	workers int,
	logger *progressLogger,
	runner CommandRunner,
) ([]wikiPageFailure, error) {
	totalTasks := len(pages)
	if totalTasks == 0 {
		return nil, nil
	}
	if workers <= 0 {
		workers = defaultWikiJobs
	}

	pageCh := make(chan WikiPage)
	var wg sync.WaitGroup
	var failureMu sync.Mutex
	var failures []wikiPageFailure
	var progressMu sync.Mutex
	finishedTasks := 0
	failedTasks := 0

	recordFailure := func(page WikiPage, err error) {
		if err == nil {
			return
		}
		failureMu.Lock()
		defer failureMu.Unlock()
		failures = append(failures, wikiPageFailure{
			Page: page,
			Err:  err,
		})
	}

	recordProgress := func(failed bool) {
		progressMu.Lock()
		finishedTasks++
		if failed {
			failedTasks++
		}
		finished := finishedTasks
		failedCount := failedTasks
		percent := float64(finished) * 100 / float64(totalTasks)
		progressMu.Unlock()

		logger.Printf("wiki page progress: %d/%d finished (%.1f%%), %d failed", finished, totalTasks, percent, failedCount)
	}

	logger.Printf("wiki page progress: 0/%d finished (0.0%%), 0 failed", totalTasks)

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for page := range pageCh {
				if err := ctx.Err(); err != nil {
					recordFailure(page, err)
					recordProgress(true)
					continue
				}
				logger.Printf("starting wiki page %s (%s)", page.ID, page.Path)
				if err := runWikiPageTask(ctx, workRoot, inputs, outputRoot, plan, page, options, logger, runner); err != nil {
					logger.Printf("failed wiki page %s (%s): %v", page.ID, page.Path, err)
					recordFailure(page, err)
					recordProgress(true)
					continue
				}
				logger.Printf("completed wiki page %s (%s)", page.ID, page.Path)
				recordProgress(false)
			}
		}()
	}

	sentPages := 0
sendLoop:
	for _, page := range pages {
		select {
		case <-ctx.Done():
			break sendLoop
		case pageCh <- page:
			sentPages++
		}
	}
	close(pageCh)
	wg.Wait()

	if err := ctx.Err(); err != nil {
		for _, page := range pages[sentPages:] {
			recordFailure(page, err)
			recordProgress(true)
		}
	}

	failureMu.Lock()
	defer failureMu.Unlock()
	if len(failures) == 0 && ctx.Err() != nil {
		return nil, ctx.Err()
	}
	return append([]wikiPageFailure(nil), failures...), nil
}

type wikiPageFailure struct {
	Page WikiPage
	Err  error
}

func wikiPageFailuresError(failures []wikiPageFailure) error {
	if len(failures) == 0 {
		return nil
	}
	var builder strings.Builder
	builder.WriteString(fmt.Sprintf("%d wiki page task(s) failed", len(failures)))
	for _, failure := range failures {
		builder.WriteString("; ")
		builder.WriteString(failure.Page.ID)
		if failure.Page.Path != "" {
			builder.WriteString(" (")
			builder.WriteString(failure.Page.Path)
			builder.WriteString(")")
		}
		builder.WriteString(": ")
		builder.WriteString(shortWikiError(failure.Err))
	}
	return fmt.Errorf("%s", builder.String())
}

func shortWikiError(err error) string {
	if err == nil {
		return ""
	}
	text := strings.TrimSpace(err.Error())
	text = strings.ReplaceAll(text, "\n", " ")
	if len(text) > 240 {
		return text[:237] + "..."
	}
	return text
}

func runCommandWithTaskTimeout(ctx context.Context, timeout time.Duration, command Command, runner CommandRunner) (CommandResult, error) {
	commandCtx := ctx
	cancel := func() {}
	if timeout > 0 {
		commandCtx, cancel = context.WithTimeout(ctx, timeout)
	}
	defer cancel()

	result, err := runner(commandCtx, command)
	if timeout > 0 && commandCtx.Err() == context.DeadlineExceeded {
		if err != nil {
			return result, fmt.Errorf("task exceeded timeout %s: %w", timeout, err)
		}
		return result, fmt.Errorf("task exceeded timeout %s: %w", timeout, context.DeadlineExceeded)
	}
	return result, err
}

func buildWikiPlannerArgs(options Options, workDir string, inputs []WikiInput, outputRoot string) []string {
	args := baseWikiArgs(options, workDir, "code-wiki:plan")
	args = append(args, buildWikiPlannerPrompt(inputs, outputRoot, options.OutputLanguage))
	return args
}

func buildWikiPageArgs(options Options, workDir string, inputs []WikiInput, outputRoot string, page WikiPage) []string {
	args := baseWikiArgs(options, workDir, "code-wiki:page:"+safeTaskID(page.ID))
	args = append(args, buildWikiPagePrompt(inputs, outputRoot, page, options.OutputLanguage))
	return args
}

func baseWikiArgs(options Options, workDir string, title string) []string {
	args := []string{"run", "--title", title}
	if strings.TrimSpace(options.OpenCodeAgent) != "" {
		args = append(args, "--agent", strings.TrimSpace(options.OpenCodeAgent))
	}
	if strings.TrimSpace(options.OpenCodeModel) != "" {
		args = append(args, "--model", strings.TrimSpace(options.OpenCodeModel))
	}
	args = append(args, options.OpenCodeExtraArgs...)
	if strings.TrimSpace(workDir) != "" {
		args = append(args, "--dir", strings.TrimSpace(workDir))
	}
	return args
}

func buildWikiPlannerPrompt(inputs []WikiInput, outputRoot string, outputLanguage string) string {
	return `Create a code wiki section plan.

Inputs:
` + formatWikiInputsForPrompt(inputs) + `

Output root:
- ` + outputRoot + `

Rules:
- Do not use Task, @explore, Explore Agent, subagents, or delegated agents. This is a single-agent task.
- Do not modify input roots.
- Read the input directories directly. For analysis repos, start with .code-analysis/*/module_plan.json when it exists, then inspect a few high-signal Markdown artifacts.
- analysis repos commonly store source-file analysis as Markdown files such as file.go.md, file.ts.md, or README.md; do not assume raw .go/.ts files exist.
- Keep exploration short, but spend the planning budget on a strong information architecture rather than file-by-file evidence mapping.
- Write exactly one file in the current working directory: ./wiki_plan.json.
- Do not answer with the plan in chat instead of writing ./wiki_plan.json.
- Do not write wiki pages in this planning task.
- Do not include source_paths or per-page evidence file lists in the plan. Page-writing tasks will discover evidence from the page title, summary, and path.
- wiki_plan.json must be valid JSON with this shape:
` + "```json" + `
{
  "schema": "` + WikiPlanSchema + `",
  "homepage": {
    "id": "home",
    "path": "README.md",
    "title": "Project Overview",
    "summary": "One sentence describing this page."
  },
  "sections": [
    {
      "id": "architecture",
      "title": "Architecture",
      "summary": "One sentence describing this level-1 section.",
      "pages": [
        {
          "id": "architecture-runtime",
          "path": "architecture/runtime.md",
          "title": "Runtime Architecture",
          "summary": "One sentence describing this level-2 page."
        }
      ]
    }
  ]
}
` + "```" + `
- The plan must include homepage and may include level-1 sections.
- Level-1 sections group pages only; they are not generated as standalone pages.
- For non-trivial repositories, choose enough level-1 sections and focused level-2 pages to explain the system well.
- Do not compress complex domains into a few generic pages. For broad subsystems such as storage, query execution, scheduling, replication, transactions, security, or observability, create narrower level-2 pages when the input supports it.
- Each level-2 page must be narrow enough for one agent task to explain in depth.
- Each page must include only id, path, title, and summary.
- Paths in the generated wiki must be markdown files under output root; put level-2 pages under their level-1 section directory.
- Do not create an extra docs/ directory.
- After drafting wiki_plan.json, review it against the project structure and complexity before exiting.
- Keep revising wiki_plan.json by splitting vague pages, adding missing subsystems, and expanding complex sections until the plan is proportionate to the repository.
- The final plan should look like an experienced maintainer's table of contents, not a shallow sample outline.
- Before exiting, verify ./wiki_plan.json exists.` + outputLanguageInstruction(outputLanguage)
}

func buildWikiPagePrompt(inputs []WikiInput, outputRoot string, page WikiPage, outputLanguage string) string {
	finalPath := filepath.Join(outputRoot, filepath.FromSlash(page.Path))
	return `Generate one code wiki page.

Inputs:
` + formatWikiInputsForPrompt(inputs) + `

Output root:
- ` + outputRoot + `

OpenCode runs in an isolated wiki page task directory. Do not write the page in the task directory; write the final page to the absolute output file below.

Final output file:
- ` + finalPath + `

Page:
- id: ` + page.ID + `
- path: ` + page.Path + `
- title: ` + page.Title + `
- summary: ` + page.Summary + `

Success condition: this task is not complete until this exact file exists on disk: ` + finalPath + `

Mandatory completion workflow:
1. Read enough evidence to write this page; do not keep exploring after you have enough context for a useful page.
2. Create the parent directory when needed.
3. Write the Markdown file to the exact final output path before sending any final response: ` + finalPath + `.
4. Run a filesystem check for that exact path.
5. If the check fails, fix the path or write the file again.
6. Only after that check passes, send a short completion note.

A chat response without the file on disk is a failed task.
If the source evidence is incomplete, still write the best accurate page you can before exiting; mark gaps and inferred claims explicitly.
Do not stop after exploration, planning, an outline, or a summary.

Rules:
- Do not use Task, @explore, Explore Agent, subagents, or delegated agents. This is a single-agent task.
- Do not modify input roots.
- Read the input directories directly. Use the page title, summary, and path to identify relevant input directories, packages, and Markdown analysis artifacts.
- analysis repos commonly store source-file analysis as Markdown files such as file.go.md, file.ts.md, or README.md; do not assume raw .go/.ts files exist.
- Write the final wiki page directly to output_root/page.path: ` + finalPath + `.
- Create parent directories for the final file when they do not already exist.
- Use the file-write/edit tool to create or replace that exact absolute path; do not rely on chat text as the output.
- Do not write page output under .code-wiki or any temporary task directory.
- Do not answer with the page content in chat instead of writing the file.
- Do not write unrelated wiki pages.
- Prefer existing Markdown analysis artifacts as evidence and synthesize them into a cohesive wiki page.
- Preserve source references so wiki readers can map claims back to input files.
- Use Mermaid diagrams only when they make relationships clearer.
- Mark inferred or uncertain claims explicitly.
- After writing, run a direct filesystem check for the exact file path, such as ` + "`test -f`, `ls -la`, or an equivalent read/stat operation." + `
- If that check fails, write the file again before exiting.
- Before exiting, verify the final output file exists at output_root/page.path: ` + finalPath + `.` + outputLanguageInstruction(outputLanguage)
}

func outputLanguageInstruction(language string) string {
	language = strings.Join(strings.Fields(language), " ")
	if language == "" {
		return ""
	}
	return "\n- Output language: Write all generated natural-language content in " + language + ". Keep code identifiers, file paths, package names, API names, CLI flags, schema names, and JSON/YAML/frontmatter keys unchanged."
}

func formatWikiInputsForPrompt(inputs []WikiInput) string {
	if len(inputs) == 0 {
		return "- none"
	}
	lines := make([]string, 0, len(inputs))
	for _, input := range inputs {
		lines = append(lines, "- "+input.Name+": "+input.Root)
	}
	return strings.Join(lines, "\n")
}

func normalizeOpenCodeBinary(binary string) string {
	binary = strings.TrimSpace(binary)
	if binary == "" {
		return defaultOpenCodeBinary
	}
	return binary
}

func readWikiPlan(path string) (WikiPlan, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return WikiPlan{}, fmt.Errorf("wiki planner did not create wiki_plan.json at %s", path)
		}
		return WikiPlan{}, fmt.Errorf("read wiki plan: %w", err)
	}
	var plan WikiPlan
	if err := json.Unmarshal(data, &plan); err != nil {
		return WikiPlan{}, fmt.Errorf("parse wiki plan: %w", err)
	}
	return plan, nil
}

func normalizeWikiPlan(plan WikiPlan) WikiPlan {
	plan.Schema = strings.TrimSpace(plan.Schema)
	plan.Homepage = normalizeWikiPage(plan.Homepage)
	sections := make([]WikiSection, 0, len(plan.Sections))
	for _, section := range plan.Sections {
		section.ID = strings.TrimSpace(section.ID)
		section.Title = strings.TrimSpace(section.Title)
		section.Summary = strings.TrimSpace(section.Summary)
		pages := make([]WikiPage, 0, len(section.Pages))
		for _, page := range section.Pages {
			pages = append(pages, normalizeWikiPage(page))
		}
		section.Pages = pages
		sections = append(sections, section)
	}
	plan.Sections = sections
	return plan
}

func normalizeWikiPlanForInputs(plan WikiPlan, inputs []WikiInput) WikiPlan {
	return normalizeWikiPlan(plan)
}

func normalizeWikiPage(page WikiPage) WikiPage {
	page.ID = strings.TrimSpace(page.ID)
	page.Path = filepath.ToSlash(filepath.Clean(strings.TrimSpace(page.Path)))
	page.Title = strings.TrimSpace(page.Title)
	page.Summary = strings.TrimSpace(page.Summary)
	return page
}

func validateWikiPlan(plan WikiPlan) error {
	if plan.Schema != WikiPlanSchema {
		return fmt.Errorf("wiki plan schema = %q, want %q", plan.Schema, WikiPlanSchema)
	}
	seen := map[string]struct{}{}
	if err := validateWikiPage(plan.Homepage, "homepage", seen); err != nil {
		return err
	}
	for i, section := range plan.Sections {
		if err := validateWikiSection(section, fmt.Sprintf("section %d", i+1), seen); err != nil {
			return err
		}
	}
	return nil
}

func validateWikiSection(section WikiSection, label string, seen map[string]struct{}) error {
	if strings.TrimSpace(section.ID) == "" {
		return fmt.Errorf("%s id is required", label)
	}
	if strings.TrimSpace(section.Title) == "" {
		return fmt.Errorf("%s title is required", label)
	}
	if len(section.Pages) == 0 {
		return fmt.Errorf("%s must include at least one page", label)
	}
	for i, page := range section.Pages {
		if err := validateWikiPage(page, fmt.Sprintf("%s page %d", label, i+1), seen); err != nil {
			return err
		}
	}
	return nil
}

func validateWikiPage(page WikiPage, label string, seen map[string]struct{}) error {
	if strings.TrimSpace(page.ID) == "" {
		return fmt.Errorf("%s id is required", label)
	}
	if strings.TrimSpace(page.Path) == "" || page.Path == "." {
		return fmt.Errorf("%s path is required", label)
	}
	if strings.HasPrefix(page.Path, "../") || strings.HasPrefix(page.Path, "/") {
		return fmt.Errorf("%s has invalid path %s", label, page.Path)
	}
	if strings.ToLower(filepath.Ext(page.Path)) != ".md" {
		return fmt.Errorf("%s path must be a markdown file: %s", label, page.Path)
	}
	if _, ok := seen[page.Path]; ok {
		return fmt.Errorf("duplicate wiki page path %s", page.Path)
	}
	seen[page.Path] = struct{}{}
	return nil
}

func wikiPlanPages(plan WikiPlan) []WikiPage {
	pages := make([]WikiPage, 0, 1)
	pages = append(pages, plan.Homepage)
	for _, section := range plan.Sections {
		pages = append(pages, section.Pages...)
	}
	return pages
}

func safeTaskID(id string) string {
	id = strings.TrimSpace(id)
	if id == "" {
		return "task"
	}
	var builder strings.Builder
	for _, r := range strings.ToLower(id) {
		switch {
		case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
			builder.WriteRune(r)
		default:
			builder.WriteByte('-')
		}
	}
	cleaned := strings.Trim(builder.String(), "-")
	if cleaned == "" {
		return "task"
	}
	return cleaned
}

func requireDirectory(label string, path string) (string, error) {
	trimmed := strings.TrimSpace(path)
	if trimmed == "" {
		return "", fmt.Errorf("%s is required", label)
	}
	absolute, err := filepath.Abs(trimmed)
	if err != nil {
		return "", fmt.Errorf("resolve %s: %w", label, err)
	}
	info, err := os.Stat(absolute)
	if err != nil {
		return "", fmt.Errorf("%s must be a directory: %w", label, err)
	}
	if !info.IsDir() {
		return "", fmt.Errorf("%s must be a directory: %s", label, absolute)
	}
	return absolute, nil
}

func ensureDirectory(label string, path string) (string, error) {
	trimmed := strings.TrimSpace(path)
	if trimmed == "" {
		return "", fmt.Errorf("%s is required", label)
	}
	absolute, err := filepath.Abs(trimmed)
	if err != nil {
		return "", fmt.Errorf("resolve %s: %w", label, err)
	}
	info, err := os.Stat(absolute)
	if err == nil {
		if !info.IsDir() {
			return "", fmt.Errorf("%s must be a directory: %s", label, absolute)
		}
		return absolute, nil
	}
	if !os.IsNotExist(err) {
		return "", fmt.Errorf("%s must be a directory: %w", label, err)
	}
	if err := os.MkdirAll(absolute, 0o755); err != nil {
		return "", fmt.Errorf("create %s: %w", label, err)
	}
	info, err = os.Stat(absolute)
	if err != nil {
		return "", fmt.Errorf("%s must be a directory: %w", label, err)
	}
	if !info.IsDir() {
		return "", fmt.Errorf("%s must be a directory: %s", label, absolute)
	}
	return absolute, nil
}

func writeJSONFile(path string, value any) error {
	data, err := json.MarshalIndent(value, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(path, data, 0o644)
}

func writeOpenCodeTaskConfig(workDir string) error {
	config := map[string]any{
		"$schema": "https://opencode.ai/config.json",
		"permission": map[string]string{
			"external_directory": "allow",
			"question":           "deny",
			"task":               "deny",
			"todowrite":          "deny",
		},
	}
	return writeJSONFile(filepath.Join(workDir, "opencode.json"), config)
}

func formatCommandError(name string, result CommandResult, err error) error {
	stderr := strings.TrimSpace(string(result.Stderr))
	if stderr != "" {
		return fmt.Errorf("run %s: %w: %s", name, err, stderr)
	}
	return fmt.Errorf("run %s: %w", name, err)
}
