// Copyright 2026 printing-press-golden. Licensed under Apache-2.0. See LICENSE.
// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.

package cli

import (
	"context"
	"encoding/json"
	"fmt"
	"math/rand"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/spf13/cobra"

	"async-job-pp-cli/internal/client"
)

// JobRow is one entry in the local jobs ledger. Rows are appended as NDJSON
// to ~/.async-job-pp-cli/jobs.jsonl; the latest row for a given JobID wins
// when listing. Pruning rewrites the file without old entries.
type JobRow struct {
	JobID          string    `json:"job_id"`
	Resource       string    `json:"resource"`
	Endpoint       string    `json:"endpoint"`
	Status         string    `json:"status"`
	SubmittedAt    time.Time `json:"submitted_at"`
	UpdatedAt      time.Time `json:"updated_at"`
	StatusResource string    `json:"status_resource,omitempty"`
	StatusEndpoint string    `json:"status_endpoint,omitempty"`
	Error          string    `json:"error,omitempty"`
}

func jobsFilePath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", fmt.Errorf("resolving home dir: %w", err)
	}
	dir := filepath.Join(home, ".async-job-pp-cli")
	if err := os.MkdirAll(dir, 0o700); err != nil {
		return "", fmt.Errorf("creating state dir: %w", err)
	}
	return filepath.Join(dir, "jobs.jsonl"), nil
}

// RecordJob appends a JobRow to the local ledger.
func RecordJob(row JobRow) error {
	p, err := jobsFilePath()
	if err != nil {
		return err
	}
	if row.SubmittedAt.IsZero() {
		row.SubmittedAt = time.Now().UTC()
	}
	row.UpdatedAt = time.Now().UTC()
	f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
	if err != nil {
		return fmt.Errorf("opening jobs ledger: %w", err)
	}
	defer f.Close()
	return json.NewEncoder(f).Encode(row)
}

func readJobRows() ([]JobRow, error) {
	p, err := jobsFilePath()
	if err != nil {
		return nil, err
	}
	data, err := os.ReadFile(p)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, fmt.Errorf("reading jobs ledger: %w", err)
	}
	var rows []JobRow
	for _, line := range strings.Split(string(data), "\n") {
		line = strings.TrimSpace(line)
		if line == "" {
			continue
		}
		var r JobRow
		if err := json.Unmarshal([]byte(line), &r); err != nil {
			continue // skip malformed rows rather than fail the whole list
		}
		rows = append(rows, r)
	}
	return rows, nil
}

// latestByJobID returns one row per JobID (the most recent by UpdatedAt).
func latestByJobID(rows []JobRow) []JobRow {
	byID := map[string]JobRow{}
	for _, r := range rows {
		existing, ok := byID[r.JobID]
		if !ok || r.UpdatedAt.After(existing.UpdatedAt) {
			byID[r.JobID] = r
		}
	}
	out := make([]JobRow, 0, len(byID))
	for _, r := range byID {
		out = append(out, r)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].SubmittedAt.After(out[j].SubmittedAt) })
	return out
}

// WaitOptions configures WaitForJob polling.
type WaitOptions struct {
	Interval time.Duration // initial poll interval; grows with backoff up to 30s
	Timeout  time.Duration // overall wait budget; 0 means no timeout
}

// WaitForJob polls statusPath with exponential backoff + jitter until the
// response reports a terminal status or the timeout elapses. Returns the
// final response body as a parsed map, or an error.
//
// Terminal statuses recognised:
//
//	done, complete, completed, success, succeeded, finished,
//	failed, errored, cancelled, canceled
func WaitForJob(ctx context.Context, c *client.Client, statusPath string, jobID string, opts WaitOptions) (map[string]any, error) {
	interval := opts.Interval
	if interval <= 0 {
		interval = 2 * time.Second
	}
	deadline := time.Time{}
	if opts.Timeout > 0 {
		deadline = time.Now().Add(opts.Timeout)
	}
	maxInterval := 30 * time.Second

	path := strings.ReplaceAll(statusPath, "{id}", jobID)
	path = strings.ReplaceAll(path, "{job_id}", jobID)

	for {
		if !deadline.IsZero() && time.Now().After(deadline) {
			return nil, fmt.Errorf("wait timed out after %s (job %s)", opts.Timeout, jobID)
		}
		if err := ctx.Err(); err != nil {
			return nil, err
		}

		// GetNoCache: the cache is keyed by (path, params), so a cached
		// non-terminal status would lock the poll on the initial response
		// for the cache TTL.
		resp, err := c.GetNoCache(path, nil)
		if err == nil {
			var body map[string]any
			if uerr := json.Unmarshal(resp, &body); uerr == nil {
				if isJobTerminal(body) {
					return body, nil
				}
			}
		}

		// Jittered sleep: interval + random up to 25%.
		jitter := time.Duration(rand.Int63n(int64(interval)/4 + 1))
		select {
		case <-time.After(interval + jitter):
		case <-ctx.Done():
			return nil, ctx.Err()
		}

		// Exponential-ish growth with cap.
		interval = interval * 3 / 2
		if interval > maxInterval {
			interval = maxInterval
		}
	}
}

// ExtractJobID parses response bytes and returns the string value of the
// named field (e.g. "job_id"). Returns "" if the response is not JSON or the
// field is missing / not a string.
func ExtractJobID(data []byte, field string) string {
	if len(data) == 0 || field == "" {
		return ""
	}
	var body map[string]any
	if err := json.Unmarshal(data, &body); err != nil {
		return ""
	}
	if v, ok := body[field].(string); ok {
		return v
	}
	// Some APIs nest the job under "data" or "job".
	for _, wrapper := range []string{"data", "job", "result"} {
		if inner, ok := body[wrapper].(map[string]any); ok {
			if v, ok := inner[field].(string); ok {
				return v
			}
		}
	}
	return ""
}

func isJobTerminal(body map[string]any) bool {
	status, _ := body["status"].(string)
	if status == "" {
		return false
	}
	switch strings.ToLower(status) {
	case "done", "complete", "completed", "success", "succeeded", "finished",
		"failed", "errored", "cancelled", "canceled":
		return true
	}
	return false
}

// newJobsCmd registers the `jobs` parent command and its subcommands.
func newJobsCmd(flags *rootFlags) *cobra.Command {
	cmd := &cobra.Command{
		Use:   "jobs",
		Short: "List and inspect async jobs tracked by this CLI",
		Long: `Jobs tracked when you submit an async-capable endpoint land in
~/.async-job-pp-cli/jobs.jsonl. This command lists, inspects, and prunes them.

Submit an async endpoint with --wait to block until completion; submit
without --wait to get the job ID back immediately and track it later.`,
		RunE: parentNoSubcommandRunE(flags),
	}
	cmd.AddCommand(newJobsListCmd(flags))
	cmd.AddCommand(newJobsGetCmd(flags))
	cmd.AddCommand(newJobsPruneCmd(flags))
	return cmd
}

func newJobsListCmd(flags *rootFlags) *cobra.Command {
	var limit int
	var all bool
	cmd := &cobra.Command{
		Use:         "list",
		Short:       "List recent async jobs",
		Annotations: map[string]string{"mcp:read-only": "true"},
		RunE: func(cmd *cobra.Command, _ []string) error {
			rows, err := readJobRows()
			if err != nil {
				return err
			}
			if !all {
				rows = latestByJobID(rows)
			}
			if limit > 0 && limit < len(rows) {
				rows = rows[:limit]
			}
			if flags.asJSON {
				return printJSONFiltered(cmd.OutOrStdout(), rows, flags)
			}
			headers := []string{"JOB_ID", "RESOURCE", "STATUS", "SUBMITTED", "UPDATED"}
			tbl := make([][]string, 0, len(rows))
			for _, r := range rows {
				tbl = append(tbl, []string{
					r.JobID,
					r.Resource + "/" + r.Endpoint,
					r.Status,
					r.SubmittedAt.Format(time.RFC3339),
					r.UpdatedAt.Format(time.RFC3339),
				})
			}
			return flags.printTable(cmd, headers, tbl)
		},
	}
	cmd.Flags().IntVar(&limit, "limit", 50, "Maximum rows to list (0 for unlimited)")
	cmd.Flags().BoolVar(&all, "all", false, "Show every state transition rather than the latest row per job")
	return cmd
}

func newJobsGetCmd(flags *rootFlags) *cobra.Command {
	return &cobra.Command{
		Use:         "get <job-id>",
		Short:       "Show the latest state row for a job",
		Annotations: map[string]string{"mcp:read-only": "true"},
		Args:        cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			rows, err := readJobRows()
			if err != nil {
				return err
			}
			rows = latestByJobID(rows)
			for _, r := range rows {
				if r.JobID == args[0] {
					if flags.asJSON {
						return printJSONFiltered(cmd.OutOrStdout(), r, flags)
					}
					fmt.Fprintf(cmd.OutOrStdout(), "job_id:    %s\nresource:  %s/%s\nstatus:    %s\nsubmitted: %s\nupdated:   %s\n",
						r.JobID, r.Resource, r.Endpoint, r.Status, r.SubmittedAt.Format(time.RFC3339), r.UpdatedAt.Format(time.RFC3339))
					if r.Error != "" {
						fmt.Fprintf(cmd.OutOrStdout(), "error:     %s\n", r.Error)
					}
					return nil
				}
			}
			return fmt.Errorf("job %q not found in local ledger", args[0])
		},
	}
}

func newJobsPruneCmd(flags *rootFlags) *cobra.Command {
	var olderThan time.Duration
	cmd := &cobra.Command{
		Use:   "prune",
		Short: "Remove job rows older than --older-than",
		RunE: func(cmd *cobra.Command, _ []string) error {
			rows, err := readJobRows()
			if err != nil {
				return err
			}
			cutoff := time.Now().Add(-olderThan)
			var kept []JobRow
			pruned := 0
			for _, r := range rows {
				if r.UpdatedAt.Before(cutoff) {
					pruned++
					continue
				}
				kept = append(kept, r)
			}

			p, err := jobsFilePath()
			if err != nil {
				return err
			}
			tmp := p + ".tmp"
			f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
			if err != nil {
				return fmt.Errorf("opening tmp ledger: %w", err)
			}
			enc := json.NewEncoder(f)
			for _, r := range kept {
				if err := enc.Encode(r); err != nil {
					_ = f.Close()
					_ = os.Remove(tmp)
					return fmt.Errorf("writing tmp ledger: %w", err)
				}
			}
			if err := f.Close(); err != nil {
				_ = os.Remove(tmp)
				return err
			}
			if err := os.Rename(tmp, p); err != nil {
				return fmt.Errorf("replacing ledger: %w", err)
			}

			if flags.asJSON {
				return printJSONFiltered(cmd.OutOrStdout(), map[string]any{"pruned": pruned, "kept": len(kept)}, flags)
			}
			fmt.Fprintf(cmd.OutOrStdout(), "pruned %d, kept %d\n", pruned, len(kept))
			return nil
		},
	}
	cmd.Flags().DurationVar(&olderThan, "older-than", 7*24*time.Hour, "Prune rows whose latest update is older than this duration")
	return cmd
}
