// Command tapir is the service entrypoint and CLI. Subcommands are dispatched
// off os.Args[1]; each lives in its own file (list.go, show.go, …). It serves
// the Stage-0 demo:
//
//	tapir auth   mint a YouTube refresh token interactively (one-time setup)
//	tapir run    detect new videos, summarize them, deliver to the store
//	tapir list   list stored summaries, recent first
//	tapir show   show one stored summary in full
//
// Standalone-vs-homelab is a wiring choice (ADR-003): every dependency is
// resolved from config (internal/config) and the SecretStore port, so live
// credentials plug in at runtime without code changes.
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"os/signal"
	"time"

	"gitea.d-ma.be/mathias/tapir/internal/adapters/secrets"
	"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
	"gitea.d-ma.be/mathias/tapir/internal/adapters/youtube"
	"gitea.d-ma.be/mathias/tapir/internal/auth"
	"gitea.d-ma.be/mathias/tapir/internal/config"
	"gitea.d-ma.be/mathias/tapir/internal/runner"
	"gitea.d-ma.be/mathias/tapir/internal/web"
	"gitea.d-ma.be/mathias/tapir/internal/web/oidc"
)

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(2)
	}

	log := slog.New(slog.NewTextHandler(os.Stderr, nil))
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	var err error
	switch os.Args[1] {
	case "list":
		err = runList(ctx, os.Args[2:])
	case "show":
		err = runShow(ctx, os.Args[2:])
	case "auth":
		err = cmdAuth(ctx, log)
	case "run":
		err = cmdRun(ctx, log)
	case "serve":
		err = cmdServe(ctx, log)
	case "report":
		err = runReport(ctx, os.Args[2:])
	default:
		usage()
		os.Exit(2)
	}

	if err != nil {
		log.Error("command failed", "command", os.Args[1], "err", err)
		os.Exit(1)
	}
}

func usage() {
	fmt.Fprint(os.Stderr, `tapir — summarize new videos from your YouTube subscriptions

usage:
  tapir auth              one-time: authorize YouTube and store a refresh token
  tapir run               detect new videos, summarize, deliver to your store
  tapir serve             run the web UI (read summaries, record watch/skip/save)
  tapir list [-limit N]   list stored summaries, recent first
  tapir show <video-id>   show one summary in full
  tapir report            Stage-0 usage gate: per-user distinct active weeks

configuration is via TAPIR_* environment variables (see .env.example).
`)
}

// cmdAuth runs the interactive OAuth flow and persists the refresh token.
func cmdAuth(ctx context.Context, log *slog.Logger) error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}
	if err := cfg.ValidateForAuth(); err != nil {
		return err
	}

	secretStore := secrets.NewFileStore(cfg.SecretsFile)
	authCfg := auth.Config{
		ClientID:     cfg.YTClientID,
		ClientSecret: cfg.YTClientSecret,
		RedirectURL:  "http://" + cfg.OAuthRedirectAddr + "/callback",
		TokenRef:     cfg.YTTokenRef,
	}
	log.Info("starting youtube authorization", "redirect", authCfg.RedirectURL, "token_ref", cfg.YTTokenRef)
	return auth.Run(ctx, authCfg, secretStore, os.Stdout)
}

// cmdRun wires the adapters and engine, then runs the watch→summarize→deliver
// loop. With TAPIR_POLL_INTERVAL unset it performs a single pass.
func cmdRun(ctx context.Context, log *slog.Logger) error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}
	if err := cfg.ValidateForRun(); err != nil {
		return err
	}

	st, err := store.New(ctx, cfg.DBDSN)
	if err != nil {
		return err
	}
	defer st.Close()

	// Same wiring the web serve path uses (buildProcessor). ValidateForRun above
	// already required the engine's inputs, so a nil here is a genuine config gap.
	engine, err := buildProcessor(cfg, st)
	if err != nil {
		return err
	}
	if engine == nil {
		return fmt.Errorf("run: incomplete summarization config (gateway, youtube credentials, secrets file)")
	}
	// Process-wide caption-fetch rate gate (ADR-014 item 2): the batch path shares
	// the same per-egress-IP limiter as the web click-path.
	youtube.SetFetchRate(cfg.FetchRate)

	r := runner.New(engine.Source, st, engine, cfg.UserID, log,
		runner.WithBackoff(cfg.FetchBackoff),
		runner.WithAutoWindow(cfg.AutoSummarizeWindow))

	log.Info("starting run", "user", cfg.UserID, "model", cfg.SummarizerModel,
		"gateway", cfg.GatewayURL, "poll_interval", cfg.PollInterval, "fetch_backoff", cfg.FetchBackoff,
		"fetch_rate", cfg.FetchRate, "auto_window", cfg.AutoSummarizeWindow)
	return r.Loop(ctx, cfg.PollInterval)
}

// cmdServe runs the Stage-1 web UI: the summary reader over the existing store
// (ADR-003 — a new transport, not new core). Auth (web.Auth) gates access; the
// registration gate resolves the authenticated subject to a tapir user_id and
// scopes every store access by it (ADR-012). With Dex configured, real OIDC login
// is used; otherwise StubAuth (dev only). The store doubles as the Identity port.
func cmdServe(ctx context.Context, log *slog.Logger) error {
	cfg, err := config.Load()
	if err != nil {
		return err
	}
	if err := cfg.ValidateForServe(); err != nil {
		return err
	}

	st, err := store.New(ctx, cfg.DBDSN)
	if err != nil {
		return err
	}
	defer st.Close()

	// Process-wide caption-fetch rate gate (ADR-014 item 2): the web click-path
	// and the scheduled-discovery runners share one per-egress-IP limiter so they
	// cannot collectively trip 429s. Must be set before either path fetches.
	youtube.SetFetchRate(cfg.FetchRate)

	// Auth seam (handlers depend on web.Auth only). With Dex configured
	// (TAPIR_OIDC_ISSUER set) serve uses real OIDC login — any Dex subject may
	// authenticate, then registers a tapir user (ADR-012); otherwise it falls
	// back to the allow-all StubAuth for local dev — never expose StubAuth publicly.
	var authn web.Auth
	if cfg.DexConfigured() {
		authn, err = oidc.New(ctx, oidc.Config{
			Issuer:        cfg.OIDCIssuer,
			ClientID:      cfg.DexClientID,
			ClientSecret:  cfg.DexClientSecret,
			RedirectURL:   cfg.OIDCRedirectURL,
			SessionSecret: cfg.SessionSecret,
		})
		if err != nil {
			return fmt.Errorf("dex oidc: %w", err)
		}
		log.Info("web auth: dex oidc", "issuer", cfg.OIDCIssuer)
	} else {
		authn = web.StubAuth{U: web.User{Subject: cfg.UserID}}
		log.Warn("web auth: STUB allow-all (no TAPIR_OIDC_ISSUER) — local dev only, do not expose")
	}

	// The file-backed SecretStore is shared by the connect flow (writes tokens)
	// and account management (deletes them on disconnect / delete-account).
	secretStore := secrets.NewFileStore(cfg.SecretsFile)
	app := &web.App{Store: st, Identity: st, Auth: authn, Secrets: secretStore, Log: log, RecencyWindow: cfg.AutoSummarizeWindow}

	// User onboarding is handled by the IdP (Authentik invite flow), not Tapir —
	// the Dex local-password provisioning path was removed (ADR-019). An
	// authenticated subject with no Tapir user is routed to /register.

	// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
	// credentials are present; the refresh token persists through the SecretStore
	// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
	// callback URL registered in the Google OAuth client's authorized redirects.
	if cfg.YTClientID != "" && cfg.YTClientSecret != "" {
		app.Connect = web.NewConnectHandler(auth.Config{
			ClientID:     cfg.YTClientID,
			ClientSecret: cfg.YTClientSecret,
			RedirectURL:  cfg.YTConnectRedirectURL,
		}, secretStore, st, log)
		log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
	}

	// Immediate summarization for the web "Summarize" button. When the engine can
	// be built (gateway + YouTube credentials + secrets present), a click runs the
	// summary now in the background; otherwise the button stays queue-only and the
	// next `tapir run` does the work (buildProcessor returns nil — never an error).
	engine, err := buildProcessor(cfg, st)
	if err != nil {
		return err
	}
	if engine != nil {
		app.Processor = &engineProcessor{engine: engine, store: st}
		log.Info("web immediate summarization enabled", "model", cfg.SummarizerModel)
	} else {
		log.Info("web summarization is queue-only (incomplete engine config)")
	}

	// In-process scheduled discovery (ADR-018): when enabled, a background
	// goroutine runs a discovery pass for ALL users on TAPIR_DISCOVERY_INTERVAL,
	// reusing the per-user runner.Runner. Cancelled by the same ctx as the server.
	//
	// SINGLE-REPLICA ASSUMPTION (load-bearing): this loop lives in the web process.
	// Running serve at >1 replica would make every replica fetch every user in
	// parallel — duplicate work and self-inflicted 429s. replicas: 1 is required in
	// the deployment manifest; scaling up needs a CronJob or leader election first.
	if cfg.DiscoveryInterval > 0 {
		log.Info("scheduled discovery enabled", "interval", cfg.DiscoveryInterval, "fetch_rate", cfg.FetchRate)
		log.Warn("scheduled discovery assumes a SINGLE replica — running serve at >1 replica double-runs discovery (ADR-018)")
		runUser := func(ctx context.Context, userID string) (runner.Stats, error) {
			r, err := buildUserRunner(cfg, st, secretStore, userID, log)
			if err != nil {
				return runner.Stats{}, err
			}
			return r.RunOnce(ctx)
		}
		go runScheduler(ctx, cfg.DiscoveryInterval, st, runUser, log)
	} else {
		log.Info("scheduled discovery disabled (TAPIR_DISCOVERY_INTERVAL unset or 0)")
	}

	srv := &http.Server{
		Addr:              cfg.HTTPAddr,
		Handler:           app.Router(),
		ReadHeaderTimeout: 10 * time.Second,
	}

	// Graceful shutdown on signal: stop accepting, drain in-flight requests.
	go func() {
		<-ctx.Done()
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		_ = srv.Shutdown(shutdownCtx)
	}()

	log.Info("serving web ui", "addr", cfg.HTTPAddr, "user", cfg.UserID)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		return fmt.Errorf("serve: %w", err)
	}
	return nil
}
