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

package cliutil

import (
	"context"
	"database/sql"
	"fmt"
	"os"
	"strings"
	"time"
)

// Decision is the outcome of a freshness check. It tells the caller what
// to do next without doing the refresh itself — EnsureFresh is pure
// decision logic so callers can compose it with their own refresh path
// (API sync, git-backed share import, or both).
type Decision int

const (
	// DecisionFresh means every requested resource is within the staleness
	// budget and the caller can serve from cache directly.
	DecisionFresh Decision = iota
	// DecisionStaleAPI means at least one requested resource is stale and
	// the caller should refresh from the upstream API.
	DecisionStaleAPI
	// DecisionStaleShare means at least one requested resource is stale
	// AND the caller configured a share remote, so the caller should
	// prefer `git pull + import` over an API hit.
	DecisionStaleShare
	// DecisionNoStore means the caller asked about freshness but the store
	// has no sync_state table yet (first run). Treated the same as stale
	// by most callers, but distinct for logging.
	DecisionNoStore
)

// String returns a stable tag suitable for logging and JSON.
func (d Decision) String() string {
	switch d {
	case DecisionFresh:
		return "fresh"
	case DecisionStaleAPI:
		return "stale-api"
	case DecisionStaleShare:
		return "stale-share"
	case DecisionNoStore:
		return "no-store"
	default:
		return "unknown"
	}
}

// FreshnessMeta is the stable agent-facing metadata shape for commands that
// participate in machine-owned freshness. Generated JSON provenance envelopes
// attach this under meta.freshness.
type FreshnessMeta struct {
	Decision  string   `json:"decision"`             // fresh, stale-api, stale-share, no-store, skipped, or error
	Ran       bool     `json:"ran"`                  // true when the hook attempted a refresh
	Reason    string   `json:"reason,omitempty"`     // fresh, refreshed, refresh_failed, data_source_live, etc.
	Resources []string `json:"resources,omitempty"`  // resource types covered by the command path
	ElapsedMS int64    `json:"elapsed_ms,omitempty"` // wall-clock spent in the freshness hook
	Error     string   `json:"error,omitempty"`      // refresh/decision error, redacted only by the caller if needed
	Source    string   `json:"source,omitempty"`     // current data-source mode: auto, local, or live
}

// Policy configures a freshness check. StaleAfter is the default threshold
// applied to any resource not listed in PerResource. PerResource is an
// override map (resource_type -> per-type threshold). EnvOptOut, when set
// and that env var equals "1", short-circuits to DecisionFresh without
// touching the database — useful for CI batch jobs and for agents that
// want to serve whatever is on disk without a refresh round trip.
// ShareEnabled controls which stale decision is returned: when true and
// the data is stale, the decision is DecisionStaleShare so the caller
// prefers a git pull over an API refresh.
type Policy struct {
	StaleAfter   time.Duration
	PerResource  map[string]time.Duration
	EnvOptOut    string
	ShareEnabled bool
}

// EnsureFresh inspects sync_state for the requested resources and returns
// a Decision. It does not refresh — callers are expected to branch on the
// decision and invoke the appropriate refresh path themselves. The ctx
// bounds the DB query only; it does not bound the caller's subsequent
// refresh work.
//
// A resource with no sync_state row or a NULL last_synced_at is treated
// as stale. The worst-case decision across resources wins — one stale
// resource makes the whole read stale.
func EnsureFresh(ctx context.Context, db *sql.DB, resources []string, policy Policy) (Decision, error) {
	if policy.EnvOptOut != "" && os.Getenv(policy.EnvOptOut) == "1" {
		return DecisionFresh, nil
	}
	if db == nil {
		return DecisionNoStore, nil
	}
	if len(resources) == 0 {
		return DecisionFresh, nil
	}

	// Build the IN clause with positional parameters — resource names come
	// from the generated command map, not user input, but sanitising with
	// placeholders is still the right instinct.
	placeholders := make([]string, len(resources))
	args := make([]any, len(resources))
	for i, r := range resources {
		placeholders[i] = "?"
		args[i] = r
	}
	query := fmt.Sprintf(
		`SELECT resource_type, last_synced_at FROM sync_state WHERE resource_type IN (%s)`,
		strings.Join(placeholders, ","),
	)

	rows, err := db.QueryContext(ctx, query, args...)
	if err != nil {
		// sync_state may not exist on a brand-new DB that was just migrated
		// but never synced. Treat as no-store so the caller knows to
		// hydrate, not as an outright error.
		if strings.Contains(err.Error(), "no such table") {
			return DecisionNoStore, nil
		}
		return DecisionFresh, fmt.Errorf("query sync_state: %w", err)
	}
	defer rows.Close()

	seen := make(map[string]bool, len(resources))
	anyStale := false
	for rows.Next() {
		var rtype string
		var lastSynced sql.NullTime
		if err := rows.Scan(&rtype, &lastSynced); err != nil {
			return DecisionFresh, fmt.Errorf("scan sync_state row: %w", err)
		}
		seen[rtype] = true
		if !lastSynced.Valid {
			anyStale = true
			continue
		}
		threshold := policy.StaleAfter
		if policy.PerResource != nil {
			if override, ok := policy.PerResource[rtype]; ok {
				threshold = override
			}
		}
		if threshold <= 0 {
			// A zero-valued threshold means "never stale" — used by callers
			// that want to opt a specific resource out of auto-refresh
			// without disabling the whole policy.
			continue
		}
		if time.Since(lastSynced.Time) > threshold {
			anyStale = true
		}
	}
	if err := rows.Err(); err != nil {
		return DecisionFresh, fmt.Errorf("iterate sync_state rows: %w", err)
	}

	// A requested resource with no row is stale by definition.
	for _, r := range resources {
		if !seen[r] {
			anyStale = true
			break
		}
	}

	if !anyStale {
		return DecisionFresh, nil
	}
	if policy.ShareEnabled {
		return DecisionStaleShare, nil
	}
	return DecisionStaleAPI, nil
}
