// Package cli: job_verify.go implements `orca job verify` (P12,
// v0.11 milestone). It performs a dry-run transaction through the lead:
// parse the jobspec, render the emitter plan (allocs/files/units),
// render a txn bundle with the desired state, stage it on the lead
// (idempotent; NO apply), run verify.sh on the lead to capture what
// WOULD be applied, and report the plan. Pre-flight drift (R-020) is
// reported but does not fail a dry-run.
//
// There are no side effects beyond the staged bundle files in
// /run/orca/txns/<txn-id>/ on the lead (idempotent; no .applied marker
// is written, so orca-pull.sh never picks it up).
package cli

import (
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"os"
	"strings"

	"github.com/spf13/cobra"

	"git.cloudinit.dev/coreci/orca/internal/emitter"
	"git.cloudinit.dev/coreci/orca/internal/jobspec"
	"git.cloudinit.dev/coreci/orca/internal/paths"
	"git.cloudinit.dev/coreci/orca/internal/secrets"
	"git.cloudinit.dev/coreci/orca/internal/spec/schema"
	"git.cloudinit.dev/coreci/orca/internal/txn"
)

var (
	jobVerifyLead      string
	jobVerifyNamespace string
	jobVerifyJSON      bool
)

// jobVerifyTransport is the SSH-push surface `job verify` needs. It
// is satisfied by *sshpush.Transport; tests substitute a mock (same
// pattern as txn.go / drain.go).
type jobVerifyTransport interface {
	WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error)
	Exec(ctx context.Context, peer string, cmd string) ([]byte, error)
}

// jobVerifyTransportOverride is the package-level seam. When non-nil
// it replaces the production transport; tests set it and restore nil.
var jobVerifyTransportOverride jobVerifyTransport

func jobVerifyTransportFromCtx() (jobVerifyTransport, error) {
	if jobVerifyTransportOverride != nil {
		return jobVerifyTransportOverride, nil
	}
	return txnTransportFromCtx()
}

// verifyReport is the structured result of `orca job verify`. It is
// rendered to JSON when --json is set, or as a human-readable summary
// otherwise.
type verifyReport struct {
	TxnID        string            `json:"txn_id"`
	Kind         string            `json:"kind"`
	Name         string            `json:"name"`
	Namespace    string            `json:"namespace,omitempty"`
	Lead         string            `json:"lead"`
	PlannedAllocs []plannedAlloc   `json:"planned_allocs"`
	PlannedFiles  []plannedFile    `json:"planned_files"`
	VerifyOutput string            `json:"verify_output,omitempty"`
	Drift        []string          `json:"drift,omitempty"`
	Status       string            `json:"status"`
}

type plannedAlloc struct {
	Name  string `json:"name"`
	Kind  string `json:"kind"`
	Count int    `json:"count"`
}

type plannedFile struct {
	Path string `json:"path"`
	Mode string `json:"mode"`
	Kind string `json:"kind"`
}

var jobVerifyCmd = &cobra.Command{
	Use:   "verify <spec>",
	Short: "Dry-run a jobspec through the lead (no apply)",
	Long: `Dry-run a jobspec as a transaction through the lead peer.

Steps (P12):
  1. Parse the jobspec
  2. Render the emitter plan (allocs, config files, systemd units)
  3. Render a txn bundle (RenderBundle) with the desired state
  4. Stage the bundle on the lead (NO apply; idempotent)
  5. Run verify.sh on the lead to capture what WOULD be applied
  6. Report planned allocs / files / units

Pre-flight drift (R-020) is reported but does not fail a dry-run.
There are no side effects beyond the staged bundle files in
/run/orca/txns/<txn-id>/ on the lead (no .applied marker is written).

Flags:
  --lead <peer>      lead peer address (host:port) (required)
  --namespace <ns>   namespace scope
  --json             JSON output

Exit codes: 0 = verify passed (no issues), 1 = verification failed.`,
	Args: cobra.ExactArgs(1),
	RunE: func(cmd *cobra.Command, args []string) error {
		report, err := runJobVerify(cmd.Context(), args[0])
		if err != nil {
			if jobVerifyJSON {
				if report != nil {
					_ = printVerifyJSON(report)
				}
			} else if report != nil {
				printVerifyText(report)
			}
			return err
		}
		if jobVerifyJSON {
			return printVerifyJSON(report)
		}
		printVerifyText(report)
		return nil
	},
}

// runJobVerify performs the dry-run. It returns the report and an
// error: when err is non-nil the report may still be populated with
// partial results (e.g. drift was detected but the verify step ran).
// The caller renders the report then surfaces the error.
func runJobVerify(ctx context.Context, path string) (*verifyReport, error) {
	if jobVerifyLead == "" {
		return nil, fmt.Errorf("--lead is required for job verify")
	}

	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read spec file: %w", err)
	}
	spec, err := jobspec.Dispatch(data, fileBase(path))
	if err != nil {
		return nil, fmt.Errorf("parse spec: %w", err)
	}

	if verr := validateForVerify(spec); verr != nil {
		return nil, verr
	}

	files, err := renderPlan(spec)
	if err != nil {
		return nil, fmt.Errorf("render plan: %w", err)
	}

	mk, err := secrets.LoadMasterKey(paths.MasterKeyPath())
	if err != nil {
		return nil, fmt.Errorf("load master key: %w", err)
	}

	desired := buildDesiredState(spec, files)
	bundle, err := txn.RenderBundle(desired, mk)
	if err != nil {
		return nil, fmt.Errorf("render bundle: %w", err)
	}

	transport, err := jobVerifyTransportFromCtx()
	if err != nil {
		return nil, fmt.Errorf("ssh transport: %w", err)
	}

	if err := txn.Stage(bundle, jobVerifyLead, asTxnTransport(transport)); err != nil {
		return nil, fmt.Errorf("stage bundle: %w", err)
	}
	slog.Info("job verify: bundle staged (no apply)", "txn_id", bundle.ID, "lead", jobVerifyLead)

	verifyOut, verifyErr := runVerifySh(ctx, transport, bundle.ID, jobVerifyLead, jobVerifyNamespace)
	drift := parseDriftLines(string(verifyOut))

	report := &verifyReport{
		TxnID:         string(bundle.ID),
		Kind:          spec.Kind,
		Name:          spec.Name,
		Namespace:     jobVerifyNamespace,
		Lead:          jobVerifyLead,
		PlannedAllocs: buildPlannedAllocs(spec),
		PlannedFiles:  buildPlannedFiles(files),
		VerifyOutput:  string(verifyOut),
		Drift:         drift,
		Status:        "verified",
	}

	if verifyErr != nil {
		report.Status = "verify-failed"
		return report, fmt.Errorf("verify.sh on %s: %w (output: %s)", jobVerifyLead, verifyErr, string(verifyOut))
	}
	return report, nil
}

// validateForVerify runs the schema validator (the dry-run should fail
// fast on an invalid spec, same as `orca job lint` errors).
func validateForVerify(spec *jobspec.WorkloadSpec) error {
	if spec == nil {
		return fmt.Errorf("spec is nil")
	}
	v, err := schema.ValidatorFor(spec.Kind)
	if err != nil {
		return err
	}
	if verr := v.Validate(spec); verr != nil {
		return fmt.Errorf("schema validation: %w", verr)
	}
	return nil
}

// renderPlan renders the spec into the emitter File artifacts that
// represent what WOULD be written on apply. The registry is populated
// with the SystemdEmitter for the "process" runtime (the only runtime
// P0c ships); other runtimes return an error so the dry-run reports
// the gap instead of pretending success.
func renderPlan(spec *jobspec.WorkloadSpec) ([]emitter.File, error) {
	if spec.Runtime == nil {
		return nil, fmt.Errorf("spec runtime is nil (verify needs a runtime to render)")
	}
	reg := emitter.NewRegistry()
	reg.Register("job:process", emitter.SystemdEmitter{})
	reg.Register("service:process", emitter.SystemdEmitter{})
	reg.Register("daemonset:process", emitter.SystemdEmitter{})
	node := &emitter.Node{Hostname: jobVerifyLead}
	return reg.Render(spec, node)
}

// buildDesiredState assembles the desired-state object the txn apply
// script consumes. It is a list of artifact dicts (path/content/mode)
// derived from the emitter plan, plus metadata so verify.sh and
// orca-pull.sh can report what would change.
func buildDesiredState(spec *jobspec.WorkloadSpec, files []emitter.File) any {
	type artifact struct {
		Path    string `json:"path"`
		Content string `json:"content"`
		Mode    string `json:"mode"`
	}
	arts := make([]artifact, len(files))
	for i, f := range files {
		arts[i] = artifact{Path: f.Path, Content: f.Content, Mode: f.Mode}
	}
	return map[string]any{
		"kind":      spec.Kind,
		"name":      spec.Name,
		"namespace": jobVerifyNamespace,
		"artifacts":  arts,
	}
}

// buildPlannedAllocs reports the alloc(s) the apply would create. For
// the single-process path it is one alloc named after the spec; for a
// task group it is one alloc with N task units. Count > 1 (Service)
// expands to N allocs.
func buildPlannedAllocs(spec *jobspec.WorkloadSpec) []plannedAlloc {
	count := spec.Count
	if count < 1 {
		count = 1
	}
	out := make([]plannedAlloc, 0, count)
	for i := 0; i < count; i++ {
		name := spec.Name
		if count > 1 {
			name = fmt.Sprintf("%s-%d", spec.Name, i)
		}
		out = append(out, plannedAlloc{Name: name, Kind: spec.Kind, Count: 1})
	}
	return out
}

// buildPlannedFiles classifies the emitter artifacts into config files
// and systemd units by path. Anything under /etc/systemd/system/ is a
// unit; everything else is a config file.
func buildPlannedFiles(files []emitter.File) []plannedFile {
	out := make([]plannedFile, 0, len(files))
	for _, f := range files {
		kind := "config"
		if strings.HasPrefix(f.Path, "/etc/systemd/system/") {
			kind = "unit"
		}
		out = append(out, plannedFile{Path: f.Path, Mode: f.Mode, Kind: kind})
	}
	return out
}

// runVerifySh runs verify.sh on the lead for the staged bundle. The
// verify script reports missing files (the ones that WOULD be written
// on apply). A non-zero exit is expected for a dry-run (the files are
// not applied yet), so the caller treats the output as informational.
func runVerifySh(ctx context.Context, transport jobVerifyTransport, id txn.TxnID, lead, namespace string) ([]byte, error) {
	dir := "/run/orca/txns/" + string(id)
	cmd := fmt.Sprintf("bash %s/verify.sh", dir)
	out, err := transport.Exec(ctx, lead, cmd)
	if err != nil {
		return out, err
	}
	return out, nil
}

// parseDriftLines extracts pre-flight drift (R-020) notices from the
// verify.sh output. The verify script emits "verify: drift <detail>"
// lines when the on-disk state has drifted from a prior apply; in a
// dry-run these are reported but do not fail.
func parseDriftLines(out string) []string {
	var drift []string
	for _, line := range strings.Split(out, "\n") {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "verify: drift") {
			drift = append(drift, strings.TrimSpace(strings.TrimPrefix(line, "verify:")))
		}
	}
	return drift
}

func printVerifyText(r *verifyReport) {
	w := rootCmd.OutOrStdout()
	fmt.Fprintf(w, "Txn: %s\n", r.TxnID)
	fmt.Fprintf(w, "Kind: %s  Name: %s\n", r.Kind, r.Name)
	if r.Namespace != "" {
		fmt.Fprintf(w, "Namespace: %s\n", r.Namespace)
	}
	fmt.Fprintf(w, "Lead: %s\n", r.Lead)
	fmt.Fprintf(w, "Status: %s\n", r.Status)
	fmt.Fprintf(w, "\nPlanned allocations (%d):\n", len(r.PlannedAllocs))
	for _, a := range r.PlannedAllocs {
		fmt.Fprintf(w, "  - %s (kind=%s)\n", a.Name, a.Kind)
	}
	units := 0
	configs := 0
	for _, f := range r.PlannedFiles {
		if f.Kind == "unit" {
			units++
		} else {
			configs++
		}
	}
	fmt.Fprintf(w, "\nPlanned files: %d config, %d systemd units\n", configs, units)
	for _, f := range r.PlannedFiles {
		fmt.Fprintf(w, "  - [%s] %s (mode %s)\n", f.Kind, f.Path, f.Mode)
	}
	if len(r.Drift) > 0 {
		fmt.Fprintf(w, "\nPre-flight drift (R-020, reported -- dry-run does not fail):\n")
		for _, d := range r.Drift {
			fmt.Fprintf(w, "  ! %s\n", d)
		}
	}
	if r.VerifyOutput != "" {
		fmt.Fprintf(w, "\nverify.sh output:\n%s\n", r.VerifyOutput)
	}
}

func printVerifyJSON(r *verifyReport) error {
	w := rootCmd.OutOrStdout()
	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(r)
}

// asTxnTransport adapts the jobVerifyTransport seam to the txn.Transport
// interface (they have the same shape; this is a thin wrapper so the
// two packages stay decoupled).
type txnTransportAdapter struct {
	inner jobVerifyTransport
}

func (a txnTransportAdapter) WriteFileIdempotent(ctx context.Context, peer string, path string, content []byte, mode os.FileMode) (bool, error) {
	return a.inner.WriteFileIdempotent(ctx, peer, path, content, mode)
}

func (a txnTransportAdapter) Exec(ctx context.Context, peer string, cmd string) ([]byte, error) {
	return a.inner.Exec(ctx, peer, cmd)
}

func asTxnTransport(t jobVerifyTransport) txn.Transport {
	return txnTransportAdapter{inner: t}
}

// fileBase returns filepath.Base(path) without importing filepath in
// the top of the file (kept here so the import block stays small).
func fileBase(path string) string {
	if i := strings.LastIndexAny(path, "/\\"); i >= 0 {
		return path[i+1:]
	}
	return path
}

func init() {
	jobVerifyCmd.Flags().StringVar(&jobVerifyLead, "lead", "", "lead peer address (host:port) (required)")
	jobVerifyCmd.Flags().StringVar(&jobVerifyNamespace, "namespace", "", "namespace scope")
	jobVerifyCmd.Flags().BoolVar(&jobVerifyJSON, "json", false, "JSON output")
	jobCmd.AddCommand(jobVerifyCmd)
}
