// Package cli: job_lint.go implements `orca job lint` (P11, REQ-084,
// v0.11 milestone). It validates a jobspec (.md/.yaml/.yml/.hcl) with:
//
//   - schema validation (kind, blocks, frontmatter fields) via
//     internal/spec/schema
//   - CEL constraint syntax check (basic -- balanced parens/quotes,
//     presence of operators; no CEL engine dependency in this phase)
//   - body preservation (R-015): warn when body is empty for .md specs
//   - migration: flag deprecated .hcl specs with a warning suggesting
//     conversion to .md (REQ-090)
//   - best-practice: warn on missing health checks for Services,
//     missing restart policies, etc.
//
// Output: one finding per line (category | severity | line | message).
// --explain prints the rationale for each finding. --format text (the
// default) or json. Exit 0 = no errors (warnings OK); 1 = errors found.
package cli

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"github.com/spf13/cobra"

	"git.cloudinit.dev/coreci/orca/internal/jobspec"
	"git.cloudinit.dev/coreci/orca/internal/spec/schema"
)

var (
	jobLintExplain bool
	jobLintFormat  string
)

type lintSeverity string

const (
	severityError   lintSeverity = "error"
	severityWarning lintSeverity = "warning"
	severityInfo    lintSeverity = "info"
)

type lintCategory string

const (
	catSchema       lintCategory = "schema"
	catCEL          lintCategory = "cel"
	catBody         lintCategory = "body"
	catMigration    lintCategory = "migration"
	catBestPractice lintCategory = "best-practice"
)

type lintFinding struct {
	Category lintCategory `json:"category"`
	Severity lintSeverity `json:"severity"`
	Line     int          `json:"line"`
	Message  string       `json:"message"`
}

var rationale = map[lintCategory]string{
	catSchema:       "Schema violations prevent the spec from being parsed or rendered; fix these first.",
	catCEL:           "CEL constraints gate placement; a syntactically invalid expression is rejected by the scheduler (REQ-083).",
	catBody:          "R-015 requires the markdown body to be preserved byte-exact; an empty body is allowed but loses operator documentation.",
	catMigration:    "HCL specs are legacy (REQ-090); convert to Markdown+frontmatter before v1.0 to keep schema validation working.",
	catBestPractice:  "Best-practice warnings do not block apply, but address them to keep the fleet observable and restartable.",
}

type lintExitError struct {
	findings []lintFinding
}

func (e *lintExitError) Error() string {
	return fmt.Sprintf("lint: %d error(s) found", countErrors(e.findings))
}

func countErrors(findings []lintFinding) int {
	n := 0
	for _, f := range findings {
		if f.Severity == severityError {
			n++
		}
	}
	return n
}

var jobLintCmd = &cobra.Command{
	Use:   "lint <spec>",
	Short: "Lint a jobspec (schema, CEL, body, migration, best-practice)",
	Long: `Validate a jobspec (.md/.yaml/.yml/.hcl) without applying it.

Checks (REQ-084):
  - schema: kind (Job/Service/DaemonSet), required blocks, frontmatter
  - CEL:    constraint expressions are syntactically valid
  - body:   markdown body present and non-empty (R-015)
  - migration: flag deprecated .hcl specs (suggest .md)
  - best-practice: warn on missing health/restart/update for the kind

Flags:
  --explain            print the rationale for each finding
  --format text|json   output format (default text)

Exit codes: 0 = no errors (warnings OK), 1 = errors found.`,
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		findings, err := runJobLint(args[0])
		if err != nil {
			var le *lintExitError
			if errors.As(err, &le) {
				if jobLintFormat == "json" {
					_ = printLintJSON(findings)
				} else {
					printLintText(findings, jobLintExplain)
				}
				return err
			}
			return err
		}
		if jobLintFormat == "json" {
			return printLintJSON(findings)
		}
		printLintText(findings, jobLintExplain)
		return nil
	},
}

func runJobLint(path string) ([]lintFinding, error) {
	var findings []lintFinding

	data, err := os.ReadFile(path)
	if err != nil {
		findings = append(findings, lintFinding{
			Category: catSchema,
			Severity: severityError,
			Line:     0,
			Message:  fmt.Sprintf("cannot read spec file: %v", err),
		})
		return findings, &lintExitError{findings}
	}

	ext := strings.ToLower(filepath.Ext(path))
	if ext == ".hcl" {
		findings = append(findings, lintFinding{
			Category: catMigration,
			Severity: severityWarning,
			Line:     0,
			Message:  fmt.Sprintf("%s is a legacy HCL spec; convert to Markdown (.md) before v1.0 (REQ-090)", filepath.Base(path)),
		})
	}

	spec, perr := jobspec.Dispatch(data, filepath.Base(path))
	if perr != nil {
		findings = append(findings, lintFinding{
			Category: catSchema,
			Severity: severityError,
			Line:     0,
			Message:  fmt.Sprintf("parse: %v", perr),
		})
		return findings, &lintExitError{findings}
	}

	findings = append(findings, lintSchema(spec)...)
	findings = append(findings, lintCEL(spec)...)
	findings = append(findings, lintBody(spec, ext)...)
	findings = append(findings, lintBestPractice(spec)...)
	findings = append(findings, lintAdvisoryFields(spec)...)

	sortLint(findings)
	if countErrors(findings) > 0 {
		return findings, &lintExitError{findings}
	}
	return findings, nil
}

func lintSchema(spec *jobspec.WorkloadSpec) []lintFinding {
	if spec == nil {
		return nil
	}
	v, err := schema.ValidatorFor(spec.Kind)
	if err != nil {
		return []lintFinding{{
			Category: catSchema,
			Severity: severityError,
			Line:     0,
			Message:  err.Error(),
		}}
	}
	verr := v.Validate(spec)
	if verr == nil {
		return nil
	}
	msg := verr.Error()
	parts := strings.Split(msg, "; ")
	var out []lintFinding
	for _, p := range parts {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		out = append(out, lintFinding{
			Category: catSchema,
			Severity: severityError,
			Line:     0,
			Message:  trimValidatorPrefix(p),
		})
	}
	return out
}

func trimValidatorPrefix(s string) string {
	if strings.HasPrefix(s, "schema/") {
		if i := strings.Index(s, ": "); i >= 0 {
			return strings.TrimSpace(s[i+2:])
		}
	}
	return s
}

func lintCEL(spec *jobspec.WorkloadSpec) []lintFinding {
	if spec == nil {
		return nil
	}
	var out []lintFinding
	for i, c := range spec.Constraints {
		if msg := basicCELCheck(c); msg != "" {
			out = append(out, lintFinding{
				Category: catCEL,
				Severity: severityError,
				Line:     0,
				Message:  fmt.Sprintf("constraints[%d]: %s", i, msg),
			})
		}
	}
	for i, a := range spec.Affinity {
		if msg := basicCELCheck(a.Target); msg != "" {
			out = append(out, lintFinding{
				Category: catCEL,
				Severity: severityError,
				Line:     0,
				Message:  fmt.Sprintf("affinity[%d].target: %s", i, msg),
			})
		}
	}
	return out
}

func basicCELCheck(expr string) string {
	expr = strings.TrimSpace(expr)
	if expr == "" {
		return "empty CEL expression"
	}
	parens := 0
	inSingle := false
	inDouble := false
	for i := 0; i < len(expr); i++ {
		c := expr[i]
		switch c {
		case '\'':
			if !inDouble {
				inSingle = !inSingle
			}
		case '"':
			if !inSingle {
				inDouble = !inDouble
			}
		case '(':
			if !inSingle && !inDouble {
				parens++
			}
		case ')':
			if !inSingle && !inDouble {
				parens--
				if parens < 0 {
					return "unbalanced parentheses: ')' before '('"
				}
			}
		}
	}
	if inSingle || inDouble {
		return "unbalanced quotes"
	}
	if parens != 0 {
		return fmt.Sprintf("unbalanced parentheses: %d unclosed '('", parens)
	}
	return ""
}

func lintBody(spec *jobspec.WorkloadSpec, ext string) []lintFinding {
	if spec == nil {
		return nil
	}
	if ext != ".md" {
		return nil
	}
	if strings.TrimSpace(spec.Body) == "" {
		return []lintFinding{{
			Category: catBody,
			Severity: severityWarning,
			Line:     0,
			Message:  "markdown body is empty (R-015: body preserved byte-exact; add operator documentation)",
		}}
	}
	return nil
}

func lintBestPractice(spec *jobspec.WorkloadSpec) []lintFinding {
	if spec == nil {
		return nil
	}
	var out []lintFinding
	switch spec.Kind {
	case "Service":
		if spec.Health == nil {
			out = append(out, lintFinding{
				Category: catBestPractice,
				Severity: severityWarning,
				Line:     0,
				Message:  "Service without a health block: Traefik routing depends on health checks (R-012)",
			})
		}
		if spec.Restart == nil {
			out = append(out, lintFinding{
				Category: catBestPractice,
				Severity: severityWarning,
				Line:     0,
				Message:  "Service without a restart policy: defaults to 'service' but an explicit policy is recommended",
			})
		}
		if spec.Update == nil {
			out = append(out, lintFinding{
				Category: catBestPractice,
				Severity: severityWarning,
				Line:     0,
				Message:  "Service without an update block: rolling/canary strategy should be explicit",
			})
		}
	case "DaemonSet":
		if spec.Restart == nil {
			out = append(out, lintFinding{
				Category: catBestPractice,
				Severity: severityWarning,
				Line:     0,
				Message:  "DaemonSet without a restart policy: a long-running daemon should declare its restart mode",
			})
		}
	case "Job":
		if spec.Restart == nil {
			out = append(out, lintFinding{
				Category: catBestPractice,
				Severity: severityInfo,
				Line:     0,
				Message:  "Job without a restart policy: defaults to 'never' (one-shot); set explicitly if retry is desired",
			})
		}
	}
	return out
}

// lintAdvisoryFields warns when a spec carries blocks that are parsed
// and validated but NOT yet enforced by the scheduler/emitter in this
// version (REQ-152/T4). Being honest about what is implemented avoids
// operators relying on a field that is silently ignored. The warnings
// are advisory (severity warning) and never block apply.
func lintAdvisoryFields(spec *jobspec.WorkloadSpec) []lintFinding {
	if spec == nil {
		return nil
	}
	var out []lintFinding
	if spec.Schedule != nil && strings.TrimSpace(spec.Schedule.Cron) != "" {
		out = append(out, lintFinding{
			Category: catBestPractice,
			Severity: severityWarning,
			Line:     0,
			Message:  "field 'schedule.cron' is not enforced in this version; it is advisory only",
		})
	}
	if spec.Health != nil {
		out = append(out, lintFinding{
			Category: catBestPractice,
			Severity: severityWarning,
			Line:     0,
			Message:  "field 'health' is not enforced in this version; it is advisory only",
		})
	}
	if spec.Update != nil {
		out = append(out, lintFinding{
			Category: catBestPractice,
			Severity: severityWarning,
			Line:     0,
			Message:  "field 'update' is not enforced in this version; it is advisory only",
		})
	}
	if len(spec.Affinity) > 0 {
		out = append(out, lintFinding{
			Category: catBestPractice,
			Severity: severityWarning,
			Line:     0,
			Message:  "field 'affinity' is not enforced in this version; it is advisory only",
		})
	}
	return out
}

func sortLint(f []lintFinding) {
	sort.SliceStable(f, func(i, j int) bool {
		si := severityRank(f[i].Severity)
		sj := severityRank(f[j].Severity)
		if si != sj {
			return si < sj
		}
		if f[i].Category != f[j].Category {
			return string(f[i].Category) < string(f[j].Category)
		}
		return f[i].Line < f[j].Line
	})
}

func severityRank(s lintSeverity) int {
	switch s {
	case severityError:
		return 0
	case severityWarning:
		return 1
	case severityInfo:
		return 2
	}
	return 3
}

func printLintText(findings []lintFinding, explain bool) {
	w := rootCmd.OutOrStdout()
	errs := countErrors(findings)
	warns := 0
	infos := 0
	for _, f := range findings {
		switch f.Severity {
		case severityWarning:
			warns++
		case severityInfo:
			infos++
		}
		line := fmt.Sprintf("%-14s %-8s %s", f.Category, f.Severity, f.Message)
		if f.Line > 0 {
			line = fmt.Sprintf("%-14s %-8s line %d: %s", f.Category, f.Severity, f.Line, f.Message)
		}
		fmt.Fprintln(w, line)
		if explain {
			fmt.Fprintf(w, "    -> %s\n", rationale[f.Category])
		}
	}
	fmt.Fprintf(w, "\n%d error(s), %d warning(s), %d info\n", errs, warns, infos)
}

func printLintJSON(findings []lintFinding) error {
	out := make([]lintFinding, len(findings))
	copy(out, findings)
	w := rootCmd.OutOrStdout()
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(out)
}

func init() {
	jobLintCmd.Flags().BoolVar(&jobLintExplain, "explain", false, "print the rationale for each finding")
	jobLintCmd.Flags().StringVar(&jobLintFormat, "format", "text", "output format: text or json")
	jobCmd.AddCommand(jobLintCmd)
}
