// Command backend is the LibNovel HTTP API server.
//
// It exposes all endpoints consumed by the SvelteKit UI: book/chapter reads,
// scrape-task creation, presigned MinIO URLs, audio-task creation, reading
// progress, live novelfire.net search, and Kokoro voice list.
//
// All heavy lifting (scraping, TTS generation) is delegated to the runner
// binary via PocketBase task records. The backend never scrapes directly.
//
// Usage:
//
//	backend          # start HTTP server (blocks until SIGINT/SIGTERM)
package main

import (
	"context"
	"fmt"
	"io"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/getsentry/sentry-go"
	"github.com/hibiken/asynq"
	"github.com/libnovel/backend/internal/asynqqueue"
	"github.com/libnovel/backend/internal/backend"
	"github.com/libnovel/backend/internal/cfai"
	"github.com/libnovel/backend/internal/config"
	"github.com/libnovel/backend/internal/kokoro"
	"github.com/libnovel/backend/internal/meili"
	"github.com/libnovel/backend/internal/otelsetup"
	"github.com/libnovel/backend/internal/pockettts"
	"github.com/libnovel/backend/internal/storage"
	"github.com/libnovel/backend/internal/taskqueue"
)

// version and commit are set at build time via -ldflags.
var (
	version = "dev"
	commit  = "unknown"
)

func main() {
	if err := run(); err != nil {
		fmt.Fprintf(os.Stderr, "backend: fatal: %v\n", err)
		os.Exit(1)
	}
}

func run() error {
	cfg := config.Load()

	// ── Sentry / GlitchTip error tracking ────────────────────────────────────
	if dsn := os.Getenv("GLITCHTIP_DSN"); dsn != "" {
		if err := sentry.Init(sentry.ClientOptions{
			Dsn:              dsn,
			Release:          version + "@" + commit,
			TracesSampleRate: 0.1,
		}); err != nil {
			fmt.Fprintf(os.Stderr, "backend: sentry init warning: %v\n", err)
		} else {
			defer sentry.Flush(2 * time.Second)
		}
	}

	// ── Logger ───────────────────────────────────────────────────────────────
	log := buildLogger(cfg.LogLevel)
	log.Info("backend starting",
		"version", version,
		"commit", commit,
		"addr", cfg.HTTP.Addr,
	)

	// ── Context: cancel on SIGINT / SIGTERM ──────────────────────────────────
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// ── OpenTelemetry tracing + logs ──────────────────────────────────────────
	otelShutdown, otelLog, err := otelsetup.Init(ctx, version)
	if err != nil {
		return fmt.Errorf("init otel: %w", err)
	}
	if otelShutdown != nil {
		defer otelShutdown()
		// Replace the plain slog logger with the OTel-bridged one so all
		// structured log lines are forwarded to Loki with trace IDs attached.
		log = otelLog
		log.Info("otel tracing + logs enabled", "endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"))
	}

	// ── Storage ──────────────────────────────────────────────────────────────
	store, err := storage.NewStore(ctx, cfg, log)
	if err != nil {
		return fmt.Errorf("init storage: %w", err)
	}

	// ── Kokoro (voice list only; audio generation is done by the runner) ─────
	var kokoroClient kokoro.Client
	if cfg.Kokoro.URL != "" {
		kokoroClient = kokoro.New(cfg.Kokoro.URL)
		log.Info("kokoro voices enabled", "url", cfg.Kokoro.URL)
	} else {
		log.Info("KOKORO_URL not set — voice list will use built-in fallback")
		kokoroClient = &noopKokoro{}
	}

	// ── Pocket-TTS (voice list + sample generation; audio generation is the runner's job) ──
	var pocketTTSClient pockettts.Client
	if cfg.PocketTTS.URL != "" {
		pocketTTSClient = pockettts.New(cfg.PocketTTS.URL)
		log.Info("pocket-tts voices enabled", "url", cfg.PocketTTS.URL)
	} else {
		log.Info("POCKET_TTS_URL not set — pocket-tts voices unavailable in backend")
	}

	// ── Cloudflare Workers AI (voice sample generation + audio-stream live TTS) ──
	var cfaiClient cfai.Client
	if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
		cfaiClient = cfai.New(cfg.CFAI.AccountID, cfg.CFAI.APIToken, cfg.CFAI.Model)
		log.Info("cloudflare AI TTS enabled", "model", cfg.CFAI.Model)
	} else {
		log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — CF AI voices unavailable in backend")
	}

	// ── Cloudflare Workers AI Image Generation ────────────────────────────────
	var imageGenClient cfai.ImageGenClient
	if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
		imageGenClient = cfai.NewImageGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
		log.Info("cloudflare AI image generation enabled")
	} else {
		log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — image generation unavailable")
	}

	// ── Cloudflare Workers AI Text Generation ─────────────────────────────────
	var textGenClient cfai.TextGenClient
	if cfg.CFAI.AccountID != "" && cfg.CFAI.APIToken != "" {
		textGenClient = cfai.NewTextGen(cfg.CFAI.AccountID, cfg.CFAI.APIToken)
		log.Info("cloudflare AI text generation enabled")
	} else {
		log.Info("CFAI_ACCOUNT_ID/CFAI_API_TOKEN not set — text generation unavailable")
	}

	// ── Meilisearch (search reads only; indexing is the runner's job) ────────
	var searchIndex meili.Client
	if cfg.Meilisearch.URL != "" {
		searchIndex = meili.New(cfg.Meilisearch.URL, cfg.Meilisearch.APIKey)
		log.Info("meilisearch search enabled", "url", cfg.Meilisearch.URL)
	} else {
		log.Info("MEILI_URL not set — search will use PocketBase substring fallback")
		searchIndex = meili.NoopClient{}
	}

	// ── Task Producer ────────────────────────────────────────────────────────
	// When REDIS_ADDR is set the backend dual-writes: PocketBase record (audit)
	// + Asynq job (immediate delivery). Otherwise it writes to PocketBase only
	// and the runner picks up on the next poll tick.
	var producer taskqueue.Producer = store
	if cfg.Redis.Addr != "" {
		redisOpt, parseErr := parseRedisOpt(cfg.Redis)
		if parseErr != nil {
			return fmt.Errorf("parse REDIS_ADDR: %w", parseErr)
		}
		asynqProducer := asynqqueue.NewProducer(store, redisOpt, log)
		defer asynqProducer.Close() //nolint:errcheck
		producer = asynqProducer
		log.Info("backend: asynq task dispatch enabled", "addr", cfg.Redis.Addr)
	} else {
		log.Info("backend: poll-mode task dispatch (REDIS_ADDR not set)")
	}

	// ── Backend server ───────────────────────────────────────────────────────
	srv := backend.New(
		backend.Config{
			Addr:         cfg.HTTP.Addr,
			DefaultVoice: cfg.Kokoro.DefaultVoice,
			Version:      version,
			Commit:       commit,
		},
		backend.Dependencies{
			BookReader:        store,
			RankingStore:      store,
			AudioStore:        store,
			TranslationStore:  store,
			PresignStore:      store,
			ProgressStore:     store,
			CoverStore:        store,
			ChapterImageStore: store,
			Producer:          producer,
			TaskReader:        store,
			SearchIndex:       searchIndex,
			Kokoro:            kokoroClient,
			PocketTTS:         pocketTTSClient,
			CFAI:              cfaiClient,
			ImageGen:          imageGenClient,
			TextGen:           textGenClient,
			BookWriter:        store,
			AIJobStore:        store,
			Log:               log,
		},
	)

	return srv.ListenAndServe(ctx)
}

// ── Helpers ───────────────────────────────────────────────────────────────────

func buildLogger(level string) *slog.Logger {
	var lvl slog.Level
	switch level {
	case "debug":
		lvl = slog.LevelDebug
	case "warn":
		lvl = slog.LevelWarn
	case "error":
		lvl = slog.LevelError
	default:
		lvl = slog.LevelInfo
	}
	return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))
}

// noopKokoro is a no-op implementation used when KOKORO_URL is not set.
// The backend only uses Kokoro for the voice list; audio generation is the
// runner's responsibility. With no URL the built-in fallback list is served.
type noopKokoro struct{}

func (n *noopKokoro) GenerateAudio(_ context.Context, _, _ string) ([]byte, error) {
	return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}

func (n *noopKokoro) StreamAudioMP3(_ context.Context, _, _ string) (io.ReadCloser, error) {
	return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}

func (n *noopKokoro) StreamAudioWAV(_ context.Context, _, _ string) (io.ReadCloser, error) {
	return nil, fmt.Errorf("kokoro not configured (KOKORO_URL is empty)")
}

func (n *noopKokoro) ListVoices(_ context.Context) ([]string, error) {
	return nil, nil
}

// parseRedisOpt converts a config.Redis into an asynq.RedisConnOpt.
// Handles full "redis://" / "rediss://" URLs and plain "host:port".
func parseRedisOpt(cfg config.Redis) (asynq.RedisConnOpt, error) {
	addr := cfg.Addr
	if len(addr) > 7 && (addr[:8] == "redis://" || (len(addr) > 8 && addr[:9] == "rediss://")) {
		return asynq.ParseRedisURI(addr)
	}
	return asynq.RedisClientOpt{
		Addr:     addr,
		Password: cfg.Password,
	}, nil
}
