// Command incredigo is a local-first, encrypted, RAM-only credential custody tool.
//
// v1 subcommands: scan, migrate, status, export, import.
// See DESIGN.md for the full architecture and threat model.
package main

import (
	"bytes"
	"context"
	"fmt"
	"io"
	"os"
	"path/filepath"
	"strings"
	"text/tabwriter"
	"time"

	"github.com/spf13/cobra"
	"golang.org/x/term"

	"incredigo/internal/audit"
	"incredigo/internal/blast"
	"incredigo/internal/discover"
	"incredigo/internal/policy"
	"incredigo/internal/rotate"
	"incredigo/internal/sink"
	"incredigo/internal/tui"
	"incredigo/internal/vault"
	"incredigo/internal/worklist"
)

var (
	flagSources  []string
	flagPaths    []string
	flagPrefix   string
	flagDedupe   bool
	flagForce    bool
	flagJSON     bool
	flagDryRun   bool
	flagAuditLog string
	flagConfig   string
	flagSealer   string
)

func main() {
	if err := newRootCmd().Execute(); err != nil {
		fmt.Fprintln(os.Stderr, "incredigo:", err)
		os.Exit(1)
	}
}

// newRootCmd builds the fully-wired root command. Extracted from main so tests can
// drive the real CLI (persistent flags + every subcommand) with SetArgs/Execute.
// Because the flag vars are package-level and pflag writes each default into its
// bound variable at registration, constructing a fresh root per call also resets
// flag state between test invocations.
func newRootCmd() *cobra.Command {
	root := &cobra.Command{
		Use:           "incredigo",
		Short:         "Local-first, encrypted, RAM-only credential custody",
		SilenceUsage:  true,
		SilenceErrors: true,
	}
	root.PersistentFlags().BoolVar(&flagDryRun, "dry-run", false, "do not write anything")
	root.PersistentFlags().StringVar(&flagAuditLog, "audit-log", "", "append-only redacted audit log path")
	root.PersistentFlags().StringVar(&flagConfig, "config", "", "policy.yaml path")
	root.PersistentFlags().StringVar(&flagSealer, "sealer", "age", "backup sealer: age (default, authenticated), hmac (authenticated, openssl-only), or openssl (unauthenticated)")

	root.AddCommand(scanCmd(), migrateCmd(), statusCmd(), exportCmd(), importCmd(), rotateCmd(), worklistCmd(), guideCmd(), passwordsCmd())
	return root
}

// applyPaths registers any --path targets with the file scanner and, when an
// explicit --source filter is in play, makes sure "file" is included so --path
// just works alongside it.
func applyPaths() {
	for _, p := range flagPaths {
		discover.AddPath(p)
	}
	if len(flagPaths) == 0 || len(flagSources) == 0 {
		return
	}
	for _, s := range flagSources {
		if s == "file" {
			return
		}
	}
	flagSources = append(flagSources, "file")
}

// gopassCredsForExecute sources the credentials to rotate from gopass (Mode A).
// It lists the entries under prefix, loads each secret into the vault, and rebuilds
// a Credential whose Source is the path's first segment after the prefix
// (e.g. imported/postgres/labapp -> Source "postgres") so the right driver Detects
// it, and whose Location is the exact gopass path the new secret must overwrite.
func gopassCredsForExecute(ctx context.Context, gp *sink.Gopass, v *vault.Vault, prefix string) ([]discover.Credential, error) {
	paths, err := gp.ListPaths(ctx, prefix)
	if err != nil {
		return nil, err
	}
	var creds []discover.Credential
	for _, p := range paths {
		h, err := gp.Show(ctx, v, p)
		if err != nil {
			return nil, err
		}
		creds = append(creds, discover.Credential{
			Source:   sourceFromStorePath(p, prefix),
			Identity: p,
			Location: p,
			Secret:   h,
		})
	}
	return creds, nil
}

// sourceFromStorePath recovers the scanner source from a gopass entry path:
// "<prefix>/<source>/<slug...>" -> "<source>".
func sourceFromStorePath(p, prefix string) string {
	rest := strings.TrimPrefix(p, strings.TrimSuffix(prefix, "/")+"/")
	if i := strings.IndexByte(rest, '/'); i >= 0 {
		return rest[:i]
	}
	return rest
}

// withVault sets up the RAM vault and guarantees it is purged on exit.
func withVault(fn func(ctx context.Context, v *vault.Vault) error) error {
	v := vault.New()
	defer v.Purge()
	return fn(context.Background(), v)
}

func scanCmd() *cobra.Command {
	c := &cobra.Command{
		Use:   "scan",
		Short: "Discover credentials (no secrets printed)",
		RunE: func(cmd *cobra.Command, args []string) error {
			applyPaths()
			return withVault(func(ctx context.Context, v *vault.Vault) error {
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				log, _ := audit.Open(flagAuditLog, time.Now)
				defer log.Close()
				w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
				fmt.Fprintln(w, "SOURCE\tKIND\tIDENTITY\tLOCATION")
				for _, cr := range creds {
					fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", cr.Source, cr.Kind, cr.Identity, cr.Location)
					log.Write(audit.Entry{Action: "scan", Source: cr.Source,
						Kind: string(cr.Kind), Identity: cr.Identity, Location: cr.Location, Outcome: "ok"})
				}
				w.Flush()
				fmt.Fprintf(os.Stderr, "\n%d credential(s) found across %d source(s)\n",
					len(creds), countSources(creds))
				return nil
			})
		},
	}
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners (comma-separated)")
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
	c.Flags().BoolVar(&flagJSON, "json", false, "json output")
	return c
}

func migrateCmd() *cobra.Command {
	c := &cobra.Command{
		Use:   "migrate",
		Short: "Migrate discovered credentials into gopass (plaintext never hits disk)",
		RunE: func(cmd *cobra.Command, args []string) error {
			applyPaths()
			return withVault(func(ctx context.Context, v *vault.Vault) error {
				gp := &sink.Gopass{Prefix: flagPrefix}
				if !gp.Available() {
					return fmt.Errorf("gopass not found on PATH")
				}
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				log, _ := audit.Open(flagAuditLog, time.Now)
				defer log.Close()

				var done, skipped int
				for _, cr := range creds {
					sp := gp.StorePath(cr)
					if flagDedupe && gp.Exists(ctx, sp) {
						skipped++
						fmt.Printf("skip  %s (exists)\n", sp)
						log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
							Identity: cr.Identity, Outcome: "skipped"})
						continue
					}
					if flagDryRun {
						fmt.Printf("would %s\n", sp)
						continue
					}
					if err := gp.Insert(ctx, v, cr, flagForce); err != nil {
						log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
							Identity: cr.Identity, Outcome: err.Error()})
						return err
					}
					done++
					fmt.Printf("store %s\n", sp)
					log.Write(audit.Entry{Action: "migrate", Source: cr.Source, Kind: string(cr.Kind),
						Identity: cr.Identity, Outcome: "ok"})
				}
				fmt.Fprintf(os.Stderr, "\nmigrated %d, skipped %d\n", done, skipped)
				return nil
			})
		},
	}
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners")
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
	c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass path prefix")
	c.Flags().BoolVar(&flagDedupe, "dedupe", false, "skip entries that already exist")
	c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing entries")
	return c
}

func statusCmd() *cobra.Command {
	c := &cobra.Command{
		Use:   "status",
		Short: "Report credential age vs expiry policy",
		RunE: func(cmd *cobra.Command, args []string) error {
			applyPaths()
			pol, err := policy.Load(flagConfig)
			if err != nil {
				return err
			}
			return withVault(func(ctx context.Context, v *vault.Vault) error {
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				now := time.Now()
				w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
				fmt.Fprintln(w, "STATE\tAGE\tSOURCE\tIDENTITY")
				for _, cr := range creds {
					st, age := pol.Evaluate(cr, now)
					fmt.Fprintf(w, "%s\t%dd\t%s\t%s\n", strings.ToUpper(string(st)),
						int(age.Hours()/24), cr.Source, cr.Identity)
				}
				w.Flush()
				return nil
			})
		},
	}
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit to these scanners")
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to harvest (repeatable)")
	return c
}

func exportCmd() *cobra.Command {
	var out string
	c := &cobra.Command{
		Use:   "export",
		Short: "Write a sealed backup bundle of the gopass prefix",
		RunE: func(cmd *cobra.Command, args []string) error {
			gp := &sink.Gopass{Prefix: flagPrefix}
			if !gp.Available() {
				return fmt.Errorf("gopass not found on PATH")
			}
			sealer, err := sealerFor(flagSealer)
			if err != nil {
				return err
			}
			if flagDryRun {
				paths, err := gp.ListPaths(context.Background(), flagPrefix)
				if err != nil {
					return err
				}
				for _, p := range paths {
					fmt.Printf("would export %s\n", p)
				}
				fmt.Fprintf(os.Stderr, "\n%d entr(ies) under %q\n", len(paths), flagPrefix)
				return nil
			}

			f, err := os.OpenFile(out, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
			if err != nil {
				return fmt.Errorf("create %s (use a fresh path; export refuses to overwrite): %w", out, err)
			}
			defer f.Close()

			return withVault(func(ctx context.Context, v *vault.Vault) error {
				h, err := readPassphrase(v, true)
				if err != nil {
					return err
				}
				pass, err := v.Open(h)
				if err != nil {
					return err
				}

				// gopass show -> framed plaintext -> pipe -> Sealer -> file.
				pr, pw := io.Pipe()
				var n int
				go func() {
					var gerr error
					n, gerr = gp.ExportTo(ctx, pw, flagPrefix)
					pw.CloseWithError(gerr)
				}()
				if err := sealer.Seal(ctx, pr, f, pass); err != nil {
					return err
				}
				log, _ := audit.Open(flagAuditLog, time.Now)
				defer log.Close()
				log.Write(audit.Entry{Action: "export", Outcome: "ok",
					Location: out, Source: sealer.Name()})
				fmt.Fprintf(os.Stderr, "sealed %d entr(ies) with %s -> %s\n", n, sealer.Name(), out)
				return nil
			})
		},
	}
	c.Flags().StringVar(&out, "out", "backup.incredigo.age", "output bundle path (refuses to overwrite)")
	c.Flags().StringVar(&flagPrefix, "prefix", "imported/", "gopass prefix to export")
	return c
}

func importCmd() *cobra.Command {
	var in string
	c := &cobra.Command{
		Use:   "import",
		Short: "Restore a sealed bundle into gopass",
		RunE: func(cmd *cobra.Command, args []string) error {
			gp := &sink.Gopass{Prefix: flagPrefix}
			if !gp.Available() {
				return fmt.Errorf("gopass not found on PATH")
			}
			sealer, err := sealerFor(flagSealer)
			if err != nil {
				return err
			}
			f, err := os.Open(in)
			if err != nil {
				return err
			}
			defer f.Close()

			return withVault(func(ctx context.Context, v *vault.Vault) error {
				h, err := readPassphrase(v, false)
				if err != nil {
					return err
				}
				pass, err := v.Open(h)
				if err != nil {
					return err
				}

				// file -> Sealer.Open -> pipe -> framed plaintext -> gopass insert.
				pr, pw := io.Pipe()
				go func() {
					pw.CloseWithError(sealer.Open(ctx, f, pw, pass))
				}()
				n, err := gp.ImportFrom(ctx, pr, flagForce)
				if err != nil {
					return err
				}
				log, _ := audit.Open(flagAuditLog, time.Now)
				defer log.Close()
				log.Write(audit.Entry{Action: "import", Outcome: "ok",
					Location: in, Source: sealer.Name()})
				fmt.Fprintf(os.Stderr, "restored %d entr(ies) from %s\n", n, in)
				return nil
			})
		},
	}
	c.Flags().StringVar(&in, "in", "backup.incredigo.age", "input bundle path")
	c.Flags().BoolVar(&flagForce, "force", false, "overwrite existing gopass entries")
	return c
}

// rotateCmd is the rotation entry point. ROTATION IS NOT IMPLEMENTED (design
// phase, see docs/ROTATION.md). The command runs the mandatory backup gate and
// prints the rotation plan, but cannot and does not change any credential: there
// are zero registered drivers, and --execute is refused.
func rotateCmd() *cobra.Command {
	var prefix, backupOut string
	var execute, dryRun, blastScan, allowMock bool
	var blastRoots []string
	c := &cobra.Command{
		Use:   "rotate",
		Short: "Plan/execute rotation behind a MANDATORY verified backup gate",
		Long: "Runs the MANDATORY backup gate (sealed, verified snapshot of the gopass prefix)\n" +
			"first, then EITHER plans (default) or executes rotation.\n\n" +
			"Default: prints the rotation plan and changes nothing. With --dry-run it walks the\n" +
			"full spine (rotate→verify→revoke) using the noop driver, touching no service.\n\n" +
			"--execute performs REAL, destructive rotation of the gopass entries under --prefix\n" +
			"(rotate→verify→store→re-read→revoke). It is refused unless INCREDIGO_ALLOW_EXECUTE=1\n" +
			"is set and at least one driver is registered. Only drivers proven against real\n" +
			"target software (proof ≥ LIVE-VM) run; MOCK-ONLY drivers are skipped unless\n" +
			"--allow-mock-proven is passed. See docs/ROTATION.md §16.",
		RunE: func(cmd *cobra.Command, args []string) error {
			if execute {
				// Real, destructive rotation. Gated by an explicit env authorization
				// on top of the flag (ROTATION.md §16), and refused if no driver can
				// actually perform a rotation.
				if os.Getenv("INCREDIGO_ALLOW_EXECUTE") != "1" {
					return fmt.Errorf("rotate --execute refused: set INCREDIGO_ALLOW_EXECUTE=1 to authorize real, destructive rotation (see docs/ROTATION.md §16)")
				}
				if len(rotate.Drivers()) == 0 {
					return fmt.Errorf("rotate --execute refused: no rotation drivers registered")
				}
			}
			applyPaths()
			gp := &sink.Gopass{Prefix: prefix}
			if !gp.Available() {
				return fmt.Errorf("gopass not found on PATH")
			}
			sealer, err := sealerFor(flagSealer)
			if err != nil {
				return err
			}
			if backupOut == "" {
				dir := filepath.Join(os.Getenv("HOME"), ".incredigo", "rotation-backups")
				if err := os.MkdirAll(dir, 0o700); err != nil {
					return err
				}
				backupOut = filepath.Join(dir, time.Now().UTC().Format("2006-01-02T15-04-05Z")+".age")
			}

			return withVault(func(ctx context.Context, v *vault.Vault) error {
				h, err := readPassphrase(v, true)
				if err != nil {
					return err
				}
				pass, err := v.Open(h)
				if err != nil {
					return err
				}

				// MANDATORY backup gate — rotation may never run without this.
				n, err := rotate.Snapshot(ctx, gp, prefix, sealer, pass, backupOut)
				if err != nil {
					return fmt.Errorf("backup gate failed — rotation would be blocked: %w", err)
				}
				fmt.Fprintf(os.Stderr, "✓ backup gate: %d entr(ies) sealed + verified -> %s\n\n", n, backupOut)

				log, _ := audit.Open(flagAuditLog, time.Now)
				defer log.Close()

				if execute {
					// Mode A — rotate what's already in gopass. Source the stored
					// entries under the prefix (the backup gate just proved coverage
					// of exactly these), then run the real rotate→verify→store→
					// re-read→revoke spine. Backup has already succeeded above.
					ecreds, err := gopassCredsForExecute(ctx, gp, v, prefix)
					if err != nil {
						return err
					}
					results := rotate.Execute(ctx, gp, ecreds, v, allowMock)
					ew := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
					fmt.Fprintln(ew, "DRIVER\tPROOF\tENTRY\tROTATED\tVERIFIED\tSTORED\tREVOKED\tERROR")
					var rotated, failed, skipped int
					for _, r := range results {
						errStr := ""
						outcome := "ok"
						switch {
						case r.Skipped:
							errStr = "SKIPPED: " + r.SkipReason
							outcome = errStr
							skipped++
						case r.Err != nil:
							errStr = r.Err.Error()
							outcome = errStr
							failed++
						default:
							rotated++
						}
						fmt.Fprintf(ew, "%s\t%s\t%s\t%t\t%t\t%t\t%t\t%s\n",
							r.Driver, rotate.ProofLevelOf(r.Driver).String(), r.StorePath, r.Rotated, r.Verified, r.Stored, r.Revoked, errStr)
						log.Write(audit.Entry{Action: "rotate-execute", Source: r.Credential.Source,
							Identity: r.StorePath, Location: backupOut, Outcome: outcome})
					}
					ew.Flush()
					fmt.Fprintf(os.Stderr, "\nROTATED %d credential(s), %d failed, %d skipped (proof gate). Backup: %s\n", rotated, failed, skipped, backupOut)
					if skipped > 0 {
						fmt.Fprintf(os.Stderr, "%d credential(s) skipped: driver proof below %s. Re-run with --allow-mock-proven to rotate them anyway.\n",
							skipped, rotate.MinExecuteProof)
					}
					if failed > 0 {
						return fmt.Errorf("rotation completed with %d failure(s) — old credential(s) left intact; restore from backup if needed", failed)
					}
					return nil
				}

				// Non-destructive plan over discoverable credentials.
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				// Optional read-only blast-radius map: which files consume each
				// credential (searched by non-secret markers only — never the secret).
				var radius []blast.CredConsumers
				if blastScan {
					radius, err = blast.Map(creds, blast.Options{Roots: blastRoots})
					if err != nil {
						return err
					}
				}

				w := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
				if blastScan {
					fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY\tCONSUMERS")
				} else {
					fmt.Fprintln(w, "DRIVER\tPROOF\tSOURCE\tIDENTITY")
				}
				for i, p := range rotate.PlanAll(creds) {
					drv := p.Driver
					// PROOF is how the driver's real-cutover path was validated
					// (LIVE-VM vs MOCK-ONLY) — surfaced so a mock-only driver can
					// never be mistaken for a live one. No driver => no proof.
					proof := "-"
					if drv == "" {
						drv = "(none)"
					} else {
						proof = rotate.ProofLevelOf(p.Driver).String()
					}
					if blastScan {
						fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d file(s)\n",
							drv, proof, p.Credential.Source, p.Credential.Identity, len(radius[i].Files()))
					} else {
						fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", drv, proof, p.Credential.Source, p.Credential.Identity)
					}
				}
				w.Flush()

				if blastScan {
					fmt.Fprintln(os.Stderr, "\nblast radius (read-only; matched by non-secret markers):")
					for _, cc := range radius {
						files := cc.Files()
						if len(files) == 0 {
							continue
						}
						fmt.Fprintf(os.Stderr, "  %s  ->  %s\n", cc.Credential.Identity, strings.Join(files, ", "))
					}
				}

				log.Write(audit.Entry{Action: "rotate-plan", Outcome: "ok", Location: backupOut})

				if dryRun {
					// Exercise the FULL rotation spine with the noop driver: it mints
					// an in-vault secret, verifies it, "revokes", and persists nothing.
					// No real credential at any service is touched.
					fmt.Fprintln(os.Stderr, "\n--dry-run: walking rotate→verify→revoke with the noop driver (touches no service)")
					results := rotate.DryRun(ctx, &rotate.NoopRotator{}, creds, v)
					dw := tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0)
					fmt.Fprintln(dw, "DRIVER\tIDENTITY\tROTATED\tVERIFIED\tREVOKED\tERROR")
					for _, r := range results {
						errStr := ""
						if r.Err != nil {
							errStr = r.Err.Error()
						}
						fmt.Fprintf(dw, "%s\t%s\t%t\t%t\t%t\t%s\n",
							r.Driver, r.Credential.Identity, r.Rotated, r.Verified, r.Revoked, errStr)
					}
					dw.Flush()
					log.Write(audit.Entry{Action: "rotate-dryrun", Outcome: "ok", Location: backupOut})
					fmt.Fprintf(os.Stderr, "\nDRY RUN complete: %d credential(s) walked, nothing persisted, no service touched.\n", len(results))
					return nil
				}

				fmt.Fprintf(os.Stderr, "\nPLAN ONLY: %d rotation driver(s) registered — nothing rotated. Add --execute (with INCREDIGO_ALLOW_EXECUTE=1) to rotate. See docs/ROTATION.md\n",
					len(rotate.Drivers()))
				return nil
			})
		},
	}
	c.Flags().StringVar(&prefix, "prefix", "imported/", "gopass prefix to back up and plan")
	c.Flags().StringVar(&backupOut, "backup-out", "", "backup bundle path (default ~/.incredigo/rotation-backups/<ts>.age)")
	c.Flags().BoolVar(&execute, "execute", false, "(reserved) perform rotation — refused in design phase")
	c.Flags().BoolVar(&dryRun, "dry-run", false, "walk the full rotation spine with the noop driver (touches no service, persists nothing)")
	c.Flags().BoolVar(&allowMock, "allow-mock-proven", false, "also execute drivers proven only against a mock/emulator (MOCK-ONLY); default skips them")
	c.Flags().BoolVar(&blastScan, "blast", false, "map each credential's blast radius — which files consume it (read-only, non-secret markers)")
	c.Flags().StringArrayVar(&blastRoots, "blast-root", nil, "directories to search for consumers (default: current directory)")
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include in the plan")
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
	return c
}

// worklistCmd emits a rotation worklist (links per credential, no secrets). It is
// read-only, offline, and rotates nothing.
func worklistCmd() *cobra.Command {
	var out string
	c := &cobra.Command{
		Use:   "worklist",
		Short: "Generate a rotation worklist with per-credential change-password links (no secrets)",
		RunE: func(cmd *cobra.Command, args []string) error {
			applyPaths()
			return withVault(func(ctx context.Context, v *vault.Vault) error {
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				md := worklist.Markdown(worklist.Build(creds))
				if out == "" {
					fmt.Print(md)
					return nil
				}
				if err := os.WriteFile(out, []byte(md), 0o644); err != nil {
					return err
				}
				fmt.Fprintf(os.Stderr, "wrote worklist (%d credential(s)) -> %s\n", len(creds), out)
				return nil
			})
		},
	}
	c.Flags().StringVar(&out, "out", "", "write the worklist to this file (default: stdout)")
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include")
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
	return c
}

// guideCmd launches the interactive guided-rotation walk-through (TUI). It is
// read-only guidance: it shows each credential and its rotation link and can open
// it, but enters/stores/rotates no secret.
func guideCmd() *cobra.Command {
	c := &cobra.Command{
		Use:   "guide",
		Short: "Interactive walk-through of credentials to rotate manually (rotates nothing)",
		RunE: func(cmd *cobra.Command, args []string) error {
			applyPaths()
			return withVault(func(ctx context.Context, v *vault.Vault) error {
				creds, err := discover.ScanAll(ctx, v, flagSources...)
				if err != nil {
					return err
				}
				return tui.Run(worklist.Build(creds))
			})
		},
	}
	c.Flags().StringArrayVar(&flagPaths, "path", nil, "extra file or directory to include")
	c.Flags().StringSliceVar(&flagSources, "source", nil, "limit discovery to these scanners")
	return c
}

// sealerFor selects the backup sealer. age is the authenticated default; openssl
// is an unauthenticated fallback (see internal/sink/openssl.go).
func sealerFor(name string) (sink.Sealer, error) {
	switch strings.ToLower(strings.TrimSpace(name)) {
	case "", "age":
		return &sink.Age{}, nil
	case "hmac":
		return &sink.HMACSealer{}, nil
	case "openssl":
		return &sink.OpenSSL{}, nil
	default:
		return nil, fmt.Errorf("unknown sealer %q (want: age, hmac, openssl)", name)
	}
}

// readPassphrase obtains a backup passphrase into the locked vault and returns its
// handle. Sources, in order: $INCREDIGO_PASSPHRASE (automation), an interactive
// no-echo TTY prompt, or one line from non-terminal stdin (piped). On a TTY,
// confirm re-prompts to guard against typos in a backup passphrase.
func readPassphrase(v *vault.Vault, confirm bool) (*vault.Handle, error) {
	if env, ok := os.LookupEnv("INCREDIGO_PASSPHRASE"); ok {
		if env == "" {
			return nil, fmt.Errorf("INCREDIGO_PASSPHRASE is set but empty")
		}
		return v.Store([]byte(env)), nil
	}

	fd := int(os.Stdin.Fd())
	if !term.IsTerminal(fd) {
		// piped stdin: read a single line as the passphrase
		line, err := readLine(os.Stdin)
		if err != nil {
			return nil, err
		}
		if len(line) == 0 {
			return nil, fmt.Errorf("empty passphrase on stdin")
		}
		return v.Store(line), nil
	}

	fmt.Fprint(os.Stderr, "Passphrase: ")
	pw1, err := term.ReadPassword(fd)
	fmt.Fprintln(os.Stderr)
	if err != nil {
		return nil, err
	}
	if len(pw1) == 0 {
		return nil, fmt.Errorf("empty passphrase")
	}
	if confirm {
		fmt.Fprint(os.Stderr, "Confirm passphrase: ")
		pw2, err := term.ReadPassword(fd)
		fmt.Fprintln(os.Stderr)
		if err != nil {
			wipe(pw1)
			return nil, err
		}
		eq := bytes.Equal(pw1, pw2)
		wipe(pw2)
		if !eq {
			wipe(pw1)
			return nil, fmt.Errorf("passphrases do not match")
		}
	}
	return v.Store(pw1), nil // Store wipes pw1
}

// readLine reads bytes up to (not including) the first newline.
func readLine(r io.Reader) ([]byte, error) {
	var out []byte
	b := make([]byte, 1)
	for {
		n, err := r.Read(b)
		if n > 0 {
			if b[0] == '\n' {
				return out, nil
			}
			out = append(out, b[0])
		}
		if err == io.EOF {
			return out, nil
		}
		if err != nil {
			return out, err
		}
	}
}

func wipe(b []byte) {
	for i := range b {
		b[i] = 0
	}
}

func countSources(creds []discover.Credential) int {
	seen := map[string]struct{}{}
	for _, c := range creds {
		seen[c.Source] = struct{}{}
	}
	return len(seen)
}
