package spindle

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"errors"
	"fmt"
	"log/slog"
	"net"
	"net/http"
	"strings"
	"sync/atomic"
	"time"

	"github.com/bluesky-social/indigo/service/tap"
	"tangled.org/core/api/tangled"
	"tangled.org/core/spindle/config"
)

func randomAdminPassword() (string, error) {
	var b [32]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", fmt.Errorf("generate tap admin password: %w", err)
	}
	return hex.EncodeToString(b[:]), nil
}

func assertLoopbackBind(bind string) error {
	host, _, err := net.SplitHostPort(bind)
	if err != nil {
		return fmt.Errorf("parse tap bind %q: %w", bind, err)
	}
	if host == "" {
		return fmt.Errorf("embedded mode requires loopback host in tap bind %q", bind)
	}
	if strings.EqualFold(host, "localhost") {
		return nil
	}
	ip := net.ParseIP(host)
	if ip == nil || !ip.IsLoopback() {
		return fmt.Errorf("embedded tap bind %q must be loopback like 127.0.0.1 or ::1", bind)
	}
	return nil
}

type embeddedTap struct {
	tap    *tap.Tap
	logger *slog.Logger
	closed atomic.Bool
}

func startEmbeddedTap(ctx context.Context, cfg *config.Config, logger *slog.Logger) (*embeddedTap, error) {
	if err := assertLoopbackBind(cfg.Server.Tap.Bind); err != nil {
		return nil, err
	}

	tcfg := tap.Config{
		DatabaseURL:                "sqlite://" + cfg.Server.Tap.DBPath,
		DBMaxConns:                 32,
		PLCURL:                     cfg.Server.PlcUrl,
		RelayUrl:                   cfg.Server.Tap.RelayUrl,
		FirehoseParallelism:        4,
		ResyncParallelism:          2,
		OutboxParallelism:          1,
		FirehoseCursorSaveInterval: time.Second,
		RepoFetchTimeout:           5 * time.Minute,
		IdentityCacheSize:          50_000,
		EventCacheSize:             10_000,
		SignalCollection:           tangled.RepoPullNSID, // HACK: to ingest PRs from any users
		CollectionFilters:          []string{tangled.RepoNSID, tangled.RepoCollaboratorNSID, tangled.RepoPullNSID},
		AdminPassword:              cfg.Server.Tap.AdminPassword,
		RetryTimeout:               60 * time.Second,
	}

	t, err := tap.New(tcfg)
	if err != nil {
		return nil, fmt.Errorf("tap.New: %w", err)
	}

	go func() {
		if err := t.Firehose.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
			logger.Error("firehose terminated", "err", err)
		}
	}()
	t.Run(ctx)
	go func() {
		logger.Info("tap http server listening", "bind", cfg.Server.Tap.Bind)
		if err := t.Server.Start(cfg.Server.Tap.Bind); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("tap http server terminated", "err", err)
		}
	}()

	if err := waitForListener(ctx, cfg.Server.Tap.Bind, time.Now().Add(10*time.Second)); err != nil {
		logger.Warn("tap http server unreachable before timeout", "bind", cfg.Server.Tap.Bind, "err", err)
	}

	return &embeddedTap{tap: t, logger: logger}, nil
}

func waitForListener(ctx context.Context, addr string, deadline time.Time) error {
	if ctx.Err() != nil {
		return ctx.Err()
	}
	if time.Now().After(deadline) {
		return fmt.Errorf("timed out waiting for %s", addr)
	}
	c, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
	if err == nil {
		c.Close()
		return nil
	}
	time.Sleep(50 * time.Millisecond)
	return waitForListener(ctx, addr, deadline)
}

func (e *embeddedTap) Shutdown() {
	if e == nil || e.tap == nil {
		return
	}
	if e.closed.Swap(true) {
		return
	}
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := e.tap.Server.Shutdown(shutdownCtx); err != nil {
		e.logger.Error("tap server shutdown failed", "err", err)
	}
	if err := e.tap.CloseDb(shutdownCtx); err != nil {
		e.logger.Error("tap db close failed", "err", err)
	}
}
