// Package narrativecheck validates that command strings in
// research.json's narrative.quickstart and narrative.recipes resolve
// against a built printed-CLI binary's Cobra tree.
//
// The narrative is LLM-authored (or hand-edited) and easily drifts from
// the actual CLI surface — e.g., research.json names `<cli> stats` but
// the real shape is `<cli> reports stats`, or a command was dropped
// because its endpoint had a complex body. Without this check, broken
// commands ship to the README's Quick Start and the SKILL's recipes;
// users hit "unknown command" on their very first copy-paste.
package narrativecheck

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"strings"

	"github.com/mvanhorn/cli-printing-press/v4/internal/shellargs"
)

// Section names the narrative section a command lives in. Matches the
// JSON path used by the bash recipe this package replaces, so log
// output is consistent across the two implementations.
type Section string

const (
	SectionQuickstart Section = "quickstart"
	SectionRecipes    Section = "recipes"
)

// Status is a command's classification after the --help walk.
type Status string

const (
	StatusOK         Status = "ok"
	StatusMissing    Status = "missing"
	StatusEmptyWords Status = "empty-words"
	// StatusExampleFailed means the command path resolved, but the full
	// narrative example failed when executed under the verify environment.
	StatusExampleFailed Status = "example-failed"
	// StatusUnsupported means full-example validation could not safely run
	// because the command does not advertise --dry-run.
	StatusUnsupported Status = "unsupported"
)

type Result struct {
	Section Section `json:"section"`
	Command string  `json:"command"`
	// Words is the extracted subcommand path (e.g., `reports stats`)
	// after stripping the binary name and the first --flag/positional.
	// Empty when the command was a bare binary or pure-flag invocation.
	Words  string `json:"words,omitempty"`
	Status Status `json:"status"`
	Error  string `json:"error,omitempty"`
	// Notes carries structural annotations about shell pieces that were
	// excluded from validation. Each entry is shaped `<reason>: <fragment>`,
	// e.g. `pipe-skipped: jq '.items[]'` or `redirect-stripped: <
	// keywords.txt`. Empty when the recipe is plain (no pipes, no redirects).
	Notes []string `json:"notes,omitempty"`
}

type Report struct {
	Walked        int      `json:"walked"`
	Missing       int      `json:"missing"`
	Empty         int      `json:"empty"`
	ExampleFailed int      `json:"example_failed,omitempty"`
	Unsupported   int      `json:"unsupported,omitempty"`
	Results       []Result `json:"results"`
	FullExamples  bool     `json:"full_examples,omitempty"`
	// ResearchEmpty is true when neither narrative.quickstart nor
	// narrative.recipes contained any entries. The LLM may have
	// omitted both sections by mistake; the caller's --strict flag
	// can decide whether that's an error.
	ResearchEmpty bool `json:"research_empty,omitempty"`
	// ResearchNotApplicable is true when the caller pointed at an
	// absent research.json, which is valid for hand-built specs that did
	// not run the Printing Press research pipeline.
	ResearchNotApplicable bool `json:"research_not_applicable,omitempty"`
}

// Options controls optional narrative validation checks.
type Options struct {
	// FullExamples validates each full narrative command, not just its
	// Cobra path. The example is run with PRINTING_PRESS_VERIFY=1 and
	// --dry-run appended when the command advertises --dry-run.
	FullExamples bool
}

// Validate parses researchPath, walks every narrative.quickstart and
// narrative.recipes command, and resolves it against the binary's
// Cobra tree by running `<binary> <words> --help`. ctx scopes every
// subprocess so callers can interrupt cleanly.
func Validate(ctx context.Context, researchPath, binaryPath string) (*Report, error) {
	return ValidateWithOptions(ctx, researchPath, binaryPath, Options{})
}

// ValidateWithOptions parses researchPath and validates every narrative
// command according to opts. The default behavior matches Validate.
func ValidateWithOptions(ctx context.Context, researchPath, binaryPath string, opts Options) (*Report, error) {
	commands, err := loadCommands(researchPath)
	if err != nil {
		return nil, err
	}

	report := &Report{
		Results:       make([]Result, 0, len(commands)),
		FullExamples:  opts.FullExamples,
		ResearchEmpty: len(commands) == 0,
	}
	for _, sc := range commands {
		r := classify(ctx, binaryPath, sc.Section, sc.Command, opts)
		switch r.Status {
		case StatusOK:
			report.Walked++
		case StatusMissing:
			report.Missing++
		case StatusEmptyWords:
			report.Empty++
		case StatusExampleFailed:
			report.ExampleFailed++
		case StatusUnsupported:
			report.Unsupported++
		}
		report.Results = append(report.Results, r)
	}
	return report, nil
}

// HasFailures reports whether the run found any missing or empty-words
// entries. Callers gate --strict exit codes on this.
func (r *Report) HasFailures() bool {
	return r.Missing > 0 || r.Empty > 0 || r.ExampleFailed > 0 || r.Unsupported > 0
}

type sectionCommand struct {
	Section Section
	Command string
}

func loadCommands(researchPath string) ([]sectionCommand, error) {
	data, err := os.ReadFile(researchPath)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return nil, fmt.Errorf("research file %s not found: %w", researchPath, err)
		}
		return nil, fmt.Errorf("reading %s: %w", researchPath, err)
	}

	// Decode just the narrative subtree we care about. Tolerates extra
	// fields in research.json (the schema is wider than narrative).
	var doc struct {
		Narrative struct {
			Quickstart []struct {
				Command string `json:"command"`
			} `json:"quickstart"`
			Recipes []struct {
				Command string `json:"command"`
			} `json:"recipes"`
		} `json:"narrative"`
	}
	if err := json.Unmarshal(data, &doc); err != nil {
		return nil, fmt.Errorf("%s is not valid JSON: %w", researchPath, err)
	}

	var out []sectionCommand
	for _, q := range doc.Narrative.Quickstart {
		if cmd := strings.TrimSpace(q.Command); cmd != "" {
			out = append(out, sectionCommand{Section: SectionQuickstart, Command: cmd})
		}
	}
	for _, r := range doc.Narrative.Recipes {
		if cmd := strings.TrimSpace(r.Command); cmd != "" {
			out = append(out, sectionCommand{Section: SectionRecipes, Command: cmd})
		}
	}
	return out, nil
}

// classify mirrors the bash recipe's wordlist rule: drop the leading
// binary name, keep words until the first flag (starts with `-`) or
// non-identifier character. Hyphens stay because Cobra subcommands use
// them (`list-projects`).
func classify(ctx context.Context, binaryPath string, section Section, command string, opts Options) Result {
	segments, err := splitShellChain(command)
	if err != nil {
		return Result{
			Section: section,
			Command: command,
			Status:  StatusExampleFailed,
			Error:   err.Error(),
		}
	}

	// Walk segments left-to-right, emitting notes in the order they appear
	// in the original command so authors can reconstruct the recipe by
	// reading down the Notes slice.
	var notes []string
	type runnableSegment struct {
		index   int
		text    string
		cleaned string
	}
	var runnable []runnableSegment
	for i, seg := range segments {
		if seg.AfterPipe {
			notes = append(notes, "pipe-skipped: "+seg.Text)
			continue
		}
		cleaned, redirects := stripRedirects(seg.Text)
		for _, r := range redirects {
			notes = append(notes, "redirect-stripped: "+r)
		}
		runnable = append(runnable, runnableSegment{index: i, text: seg.Text, cleaned: cleaned})
	}

	finish := func(r Result) Result {
		r.Command = command
		r.Notes = append(r.Notes, notes...)
		return r
	}

	if len(runnable) == 0 {
		// Every chained segment landed on the right side of a pipe — there
		// is no runnable head. Surface this as empty-words so the author
		// notices, but keep the pipe-skipped notes for context.
		return finish(Result{
			Section: section,
			Status:  StatusEmptyWords,
			Error:   "command has no runnable segment (every chained segment is pipe-skipped)",
		})
	}

	var last Result
	for i, seg := range runnable {
		sub := classifySegment(ctx, binaryPath, section, seg.cleaned, opts)
		if sub.Status != StatusOK {
			if len(runnable) > 1 {
				sub.Error = fmt.Sprintf("segment %d (%q): %s", i+1, seg.cleaned, sub.Error)
			}
			return finish(sub)
		}
		last = sub
	}
	return finish(last)
}

func classifySegment(ctx context.Context, binaryPath string, section Section, command string, opts Options) Result {
	words := extractSubcommandWords(command)
	r := Result{Section: section, Command: command, Words: strings.Join(words, " ")}

	if len(words) == 0 {
		r.Status = StatusEmptyWords
		r.Error = "command has no subcommand words to verify (bare binary or pure-flag invocation)"
		return r
	}

	helpArgs := append(words, "--help")
	if !opts.FullExamples {
		if err := exec.CommandContext(ctx, binaryPath, helpArgs...).Run(); err != nil {
			r.Status = StatusMissing
			r.Error = fmt.Sprintf("%s %s --help failed: %v", binaryPath, r.Words, err)
			return r
		}

		r.Status = StatusOK
		return r
	}

	helpOut, err := exec.CommandContext(ctx, binaryPath, helpArgs...).CombinedOutput()
	if err != nil {
		r.Status = StatusMissing
		r.Error = fmt.Sprintf("%s %s --help failed: %v", binaryPath, r.Words, err)
		return r
	}
	return classifyFullExample(ctx, binaryPath, command, helpOut, r)
}

func classifyFullExample(ctx context.Context, binaryPath, command string, helpOut []byte, r Result) Result {
	tokens, err := shellargs.Split(command)
	if err != nil {
		r.Status = StatusExampleFailed
		r.Error = err.Error()
		return r
	}
	if len(tokens) <= 1 {
		r.Status = StatusEmptyWords
		r.Error = "command has no arguments to execute after the binary name"
		return r
	}

	args := append([]string(nil), tokens[1:]...)
	if isSideEffectfulNarrativeExample(args) {
		r.Status = StatusUnsupported
		r.Error = "full-example validation skipped: command is side-effectful (auth/launch/apply)"
		return r
	}
	if !hasEnabledBoolFlag(args, "--dry-run") {
		if !helpAdvertisesDryRun(helpOut) {
			r.Status = StatusUnsupported
			r.Error = "full-example validation skipped: command help does not advertise --dry-run"
			return r
		}
		args = append(args, "--dry-run")
	}

	cmd := exec.CommandContext(ctx, binaryPath, args...)
	// Mirror the verify pipeline's mock-mode contract: VERIFY=1 lets
	// generated commands short-circuit visible side effects, and
	// VERIFY_LIVE_HTTP=1 opts back in to the real wire path so the
	// transport-layer mutating-verb gate doesn't collapse narrative
	// full-example assertions to a synthetic envelope.
	cmd.Env = append(os.Environ(), "PRINTING_PRESS_VERIFY=1", "PRINTING_PRESS_VERIFY_LIVE_HTTP=1")
	out, err := cmd.CombinedOutput()
	if err != nil {
		r.Status = StatusExampleFailed
		r.Error = fmt.Sprintf("full example failed: %s %s: %v%s",
			binaryPath,
			strings.Join(args, " "),
			err,
			formatOutputSuffix(out),
		)
		return r
	}

	r.Status = StatusOK
	return r
}

func isSideEffectfulNarrativeExample(args []string) bool {
	if len(args) >= 2 && args[0] == "auth" {
		switch args[1] {
		case "set-token", "logout", "setup", "login":
			return true
		}
	}

	if hasEnabledBoolFlag(args, "--launch") {
		return true
	}
	hasApply := hasEnabledBoolFlag(args, "--apply")
	return hasApply && !hasEnabledBoolFlag(args, "--dry-run")
}

func hasEnabledBoolFlag(args []string, flag string) bool {
	for _, arg := range args {
		if arg == flag || arg == flag+"=true" {
			return true
		}
	}
	return false
}

func helpAdvertisesDryRun(out []byte) bool {
	return strings.Contains(string(out), "--dry-run")
}

func formatOutputSuffix(out []byte) string {
	trimmed := strings.TrimSpace(string(out))
	if trimmed == "" {
		return ""
	}
	const max = 500
	if len(trimmed) > max {
		trimmed = trimmed[:max] + "..."
	}
	return ": " + trimmed
}

// extractSubcommandWords replicates the bash recipe's awk wordlist
// extraction so the Go and bash implementations classify identically:
//
//	for (i=2; i<=NF; i++) {
//	  if ($i ~ /^-/ || $i ~ /[^a-zA-Z0-9_-]/) break
//	  print $i
//	}
//
// Strip the first token (binary name), then keep tokens until the first
// flag or any token containing a character outside [A-Za-z0-9_-].
func extractSubcommandWords(command string) []string {
	tokens := strings.Fields(command)
	if len(tokens) <= 1 {
		return nil
	}
	var words []string
	for _, tok := range tokens[1:] {
		if strings.HasPrefix(tok, "-") || !isIdentifierToken(tok) {
			break
		}
		words = append(words, tok)
	}
	return words
}

// chainSegment is one runnable (or pipe-skipped) piece of a recipe command.
type chainSegment struct {
	// Text is the segment as it appeared in the source, with surrounding
	// whitespace trimmed. Pipe and redirect tokens are NOT stripped here —
	// stripRedirects handles that downstream.
	Text string
	// AfterPipe is true when this segment sat to the right of a top-level
	// `|` operator. The validator skips these segments because their input
	// would normally arrive over a shell pipe.
	AfterPipe bool
}

// splitShellChain walks command and returns the segments separated by
// top-level `&&`, `||`, `;`, or `|` operators. Quoted text is preserved.
// Segments after a top-level `|` carry AfterPipe=true; the validator
// reports those as `pipe-skipped` rather than executing them.
//
// Backslash-escapes and quoted-string handling mirror shellargs.Split
// so the segments are safe to feed back into shellargs.Split.
func splitShellChain(command string) ([]chainSegment, error) {
	var (
		segments  []chainSegment
		quote     rune
		escaped   bool
		afterPipe bool
		start     int
	)
	flush := func(end int) {
		if seg := strings.TrimSpace(command[start:end]); seg != "" {
			segments = append(segments, chainSegment{Text: seg, AfterPipe: afterPipe})
		}
	}
	for i := 0; i < len(command); i++ {
		c := command[i]
		if escaped {
			escaped = false
			continue
		}
		if quote == '\'' {
			if c == '\'' {
				quote = 0
			}
			continue
		}
		if quote == '"' {
			switch c {
			case '\\':
				escaped = true
			case '"':
				quote = 0
			}
			continue
		}
		switch c {
		case '\\':
			escaped = true
		case '\'', '"':
			quote = rune(c)
		case '&':
			if i+1 < len(command) && command[i+1] == '&' {
				flush(i)
				i++
				start = i + 1
				// && resets the pipeline; segments after && start fresh.
				afterPipe = false
			}
		case '|':
			if i+1 < len(command) && command[i+1] == '|' {
				flush(i)
				i++
				start = i + 1
				// || resets the pipeline too.
				afterPipe = false
				continue
			}
			// Bare `|` ends the current runnable segment and marks every
			// subsequent segment in this && / || / ; group as pipe-skipped.
			flush(i)
			start = i + 1
			afterPipe = true
		case ';':
			flush(i)
			start = i + 1
			afterPipe = false
		}
	}
	if quote != 0 {
		return nil, fmt.Errorf("unclosed %s quote in %q", quoteName(quote), command)
	}
	flush(len(command))
	return segments, nil
}

// stripRedirects removes top-level shell redirects (`<file`, `>file`, `>>file`)
// from a segment and returns the cleaned text plus the human-readable redirect
// fragments that were excised (e.g. `< keywords.txt`). The validator records
// the fragments as `redirect-stripped` notes so authors see what the runtime
// dropped. Redirects inside quoted strings and `2>&1`-style fd duplications
// are left alone.
func stripRedirects(segment string) (string, []string) {
	var (
		cleaned   strings.Builder
		redirects []string
		quote     rune
		escaped   bool
	)
	cleaned.Grow(len(segment))
	bytes := []byte(segment)
	i := 0
	for i < len(bytes) {
		c := bytes[i]
		if escaped {
			cleaned.WriteByte(c)
			escaped = false
			i++
			continue
		}
		if quote != 0 {
			cleaned.WriteByte(c)
			switch {
			case quote == '\'' && c == '\'':
				quote = 0
			case quote == '"' && c == '\\':
				escaped = true
			case quote == '"' && c == '"':
				quote = 0
			}
			i++
			continue
		}
		switch c {
		case '\\':
			cleaned.WriteByte(c)
			escaped = true
			i++
		case '\'', '"':
			cleaned.WriteByte(c)
			quote = rune(c)
			i++
		case '<', '>':
			// fd duplication like `2>&1` is signaled by a preceding digit AND
			// a following `&`. Bare `>&file` is `&>file` shorthand (a real
			// redirect target) and must still be stripped, so the guard only
			// fires when both signals are present.
			prevIsDigit := cleaned.Len() > 0 && cleaned.String()[cleaned.Len()-1] >= '0' && cleaned.String()[cleaned.Len()-1] <= '9'
			if c == '>' && prevIsDigit && i+1 < len(bytes) && bytes[i+1] == '&' {
				cleaned.WriteByte(c)
				i++
				continue
			}
			op := string(c)
			if c == '>' && i+1 < len(bytes) && bytes[i+1] == '>' {
				op = ">>"
				i++
			}
			i++
			for i < len(bytes) && (bytes[i] == ' ' || bytes[i] == '\t') {
				i++
			}
			fileStart := i
			fileQuote := rune(0)
			fileEsc := false
			for i < len(bytes) {
				b := bytes[i]
				if fileEsc {
					fileEsc = false
					i++
					continue
				}
				if fileQuote == '\'' {
					if b == '\'' {
						fileQuote = 0
					}
					i++
					continue
				}
				if fileQuote == '"' {
					switch b {
					case '\\':
						fileEsc = true
					case '"':
						fileQuote = 0
					}
					i++
					continue
				}
				switch b {
				case '\'', '"':
					fileQuote = rune(b)
					i++
					continue
				case '\\':
					fileEsc = true
					i++
					continue
				case ' ', '\t':
					goto fileDone
				}
				i++
			}
		fileDone:
			fragment := strings.TrimSpace(op + " " + string(bytes[fileStart:i]))
			redirects = append(redirects, fragment)
			// Skip any whitespace that follows the file token so a
			// redirect-stripped span sandwiched between two flags collapses
			// to a single space.
			for i < len(bytes) && (bytes[i] == ' ' || bytes[i] == '\t') {
				i++
			}
			trimmed := strings.TrimRight(cleaned.String(), " \t")
			cleaned.Reset()
			cleaned.WriteString(trimmed)
			cleaned.WriteByte(' ')
		default:
			cleaned.WriteByte(c)
			i++
		}
	}
	return strings.TrimSpace(cleaned.String()), redirects
}

func quoteName(r rune) string {
	if r == '\'' {
		return "single"
	}
	return "double"
}

// isIdentifierToken reports whether s contains only ASCII alphanumerics,
// underscores, and hyphens. Anything else (=, :, /, quotes, JSON-string
// arguments, etc.) signals the start of a non-subcommand token and ends
// the wordlist scan.
func isIdentifierToken(s string) bool {
	if s == "" {
		return false
	}
	for _, r := range s {
		switch {
		case r >= 'a' && r <= 'z':
		case r >= 'A' && r <= 'Z':
		case r >= '0' && r <= '9':
		case r == '_' || r == '-':
		default:
			return false
		}
	}
	return true
}
