// tmctl is the TextMachine CLI: translate / report / status / redrive.
// main.go — тонкая обвязка (пакет №4): разбор аргументов — invocation.go,
// рендеры вывода — render.go, .env — dotenv.go; здесь только связка
// «parse → env → ctx → fetch → render» и маппинг exit-кодов.
package main

import (
	"context"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"path/filepath"
	"syscall"

	"textmachine/backend/internal/obs"
	"textmachine/backend/internal/pipeline"
	"textmachine/backend/internal/store"
)

func main() {
	err := run()
	if err != nil {
		fmt.Fprintln(os.Stderr, "tmctl:", err)
	}
	os.Exit(exitCode(err))
}

// exitCode maps a run() error onto the ratified shell contract (Веха 2): 0 clean ·
// 2 completed-with-flags (приёмка допускает N флагов; типизированный сентинел
// *pipeline.CompletedWithFlags, переживает %w-обёртки через errors.As) · 1 infra
// failure и всё остальное, включая ошибку разбора флагов (ContinueOnError в
// parseInvocation держит 2 эксклюзивным для флагнутых чанков).
func exitCode(err error) int {
	var flagged *pipeline.CompletedWithFlags
	switch {
	case err == nil:
		return 0
	case errors.As(err, &flagged):
		return 2
	default:
		return 1
	}
}

func run() error {
	inv, err := parseInvocation(os.Args[1:], os.Stderr)
	if err != nil {
		return err
	}

	loadDotEnv(filepath.Join(filepath.Dir(inv.cfgPath), ".env"))
	loadDotEnv(".env")

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()
	// LogBodies решается один раз на допуске (privacy by default): содержимое
	// LLM-обменов попадает в логи только при LOG_LLM_BODIES=1 И LOG_LEVEL=debug.
	ctx = obs.WithReqInfo(ctx, obs.ReqInfo{
		TraceID:   obs.NewTraceID(),
		LogBodies: os.Getenv("LOG_LLM_BODIES") == "1",
	})

	switch inv.cmd {
	case "translate":
		return translate(ctx, inv.cfgPath, inv.resnapshot)
	case "report":
		return report(inv.cfgPath)
	case "status":
		return status(ctx, inv.cfgPath, inv.asJSON)
	case "redrive":
		return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel)
	default:
		return fmt.Errorf("unknown command %q (want translate|report|status|redrive)", inv.cmd)
	}
}

func translate(ctx context.Context, cfgPath string, resnapshot bool) error {
	r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
	if err != nil {
		return err
	}
	defer r.Close()
	r.Resnapshot = resnapshot

	res, err := r.TranslateBook(ctx)
	if err != nil {
		return err
	}
	return renderTranslate(os.Stdout, res, func() (float64, float64, error) {
		return r.Store.SpentUSD(res.BookID)
	})
}

func report(cfgPath string) error {
	// Read-only ($0): no API keys required (D20.4 — a store audit must not demand provider keys).
	r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
	if err != nil {
		return err
	}
	defer r.Close()

	// Чтения — коллбеками (interleaved с печатью, как исторически): провал чтения
	// посреди аудита оставляет уже напечатанную часть на stdout (см. renderReport).
	return renderReport(os.Stdout,
		func() ([]store.RequestLogView, error) { return r.Store.RequestLogRows(r.Book.BookID) },
		func() ([]store.ChunkStatus, error) { return r.Store.ChunkStatusesForBook(r.Book.BookID) },
		func() ([]store.RetrievalState, error) { return r.Store.RetrievalStatesForBook(r.Book.BookID) },
		func() (float64, float64, error) { return r.Store.SpentUSD(r.Book.BookID) })
}

// status prints the READ-ONLY progress projection (D15.3): 0 LLM, 0 replay. --json emits the
// StatusReport with stable disposition/flag_reason enums for CI/IDE; the default is a human
// dashboard (unit counts, per-chapter quality passports, money, secondary ETA). BOTH modes exit 2
// (completed-with-flags) when flagged>0 (minor 1d — --json used to always exit 0), so a consumer
// never reads a clean 0 over a book that still needs attention.
func status(ctx context.Context, cfgPath string, asJSON bool) error {
	// Read-only ($0): no API keys required (D20.4 — a progress projection must not demand keys).
	r, err := pipeline.NewReadOnlyRunner(cfgPath, obs.NewLogger())
	if err != nil {
		return err
	}
	defer r.Close()

	rep, err := r.Status(ctx)
	if err != nil {
		return err
	}
	if asJSON {
		return renderStatusJSON(os.Stdout, rep)
	}
	return renderStatusHuman(os.Stdout, rep, cfgPath)
}

// redrive re-attacks the FLAGGED chunks matching the selector (D15.3): it resets their terminal
// flag (chunk_status + checkpoints of the flagged/skipped stages) and re-runs the durable loop
// with a FRESH retry/escalation budget, never touching DispOK work. On --dry-run it only reports
// the plan. Redrive-calls bill normally; money already spent on the discarded attempts stays
// committed (honest). By default it requires the current config to render the SAME snapshot the
// flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips
// that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but
// silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it).
func redrive(ctx context.Context, cfgPath string, resnapshot bool, sel pipeline.RedriveSelector) error {
	r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
	if err != nil {
		return err
	}
	defer r.Close()
	r.Resnapshot = resnapshot

	summary, res, err := r.Redrive(ctx, sel)
	if err != nil {
		return err
	}
	return renderRedrive(os.Stdout, r.Book.BookID, summary, res, func() (float64, float64, error) {
		return r.Store.SpentUSD(r.Book.BookID)
	})
}
