package clickhouse

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"strings"
	"sync"
	"time"

	"github.com/ClickHouse/clickhouse-go/v2"
	"github.com/antitbone/ja4/correlator/internal/domain"
	"github.com/antitbone/ja4/correlator/internal/observability"
)

const (
	// DefaultBatchSize is the default number of records per batch
	DefaultBatchSize = 500
	// DefaultFlushIntervalMs is the default flush interval in milliseconds
	DefaultFlushIntervalMs = 200
	// DefaultMaxBufferSize is the default maximum buffer size
	DefaultMaxBufferSize = 5000
	// DefaultTimeoutMs is the default timeout for operations in milliseconds
	DefaultTimeoutMs = 1000
	// DefaultPingTimeoutMs is the timeout for initial connection ping
	DefaultPingTimeoutMs = 5000
	// MaxRetries is the maximum number of retry attempts for failed inserts
	MaxRetries = 3
	// RetryBaseDelay is the base delay between retries
	RetryBaseDelay = 100 * time.Millisecond
)

// Config holds the ClickHouse sink configuration.
type Config struct {
	DSN             string
	Table           string
	BatchSize       int
	FlushIntervalMs int
	MaxBufferSize   int
	DropOnOverflow  bool
	AsyncInsert     bool
	TimeoutMs       int
}

// ClickHouseSink writes correlated logs to ClickHouse.
type ClickHouseSink struct {
	config    Config
	conn      clickhouse.Conn
	mu        sync.Mutex
	buffer    []domain.CorrelatedLog
	flushChan chan struct{}
	done      chan struct{}
	wg        sync.WaitGroup
	closeOnce sync.Once
	logger    *observability.Logger
}

// SetLogger sets the logger used by the sink.
func (s *ClickHouseSink) SetLogger(logger *observability.Logger) {
	s.logger = logger.WithFields(map[string]any{"sink": "clickhouse"})
}

// NewClickHouseSink creates a new ClickHouse sink.
func NewClickHouseSink(config Config) (*ClickHouseSink, error) {
	if strings.TrimSpace(config.DSN) == "" {
		return nil, fmt.Errorf("clickhouse DSN is required")
	}
	if strings.TrimSpace(config.Table) == "" {
		return nil, fmt.Errorf("clickhouse table is required")
	}

	// Apply defaults
	if config.BatchSize <= 0 {
		config.BatchSize = DefaultBatchSize
	}
	if config.FlushIntervalMs <= 0 {
		config.FlushIntervalMs = DefaultFlushIntervalMs
	}
	if config.MaxBufferSize <= 0 {
		config.MaxBufferSize = DefaultMaxBufferSize
	}
	if config.TimeoutMs <= 0 {
		config.TimeoutMs = DefaultTimeoutMs
	}

	s := &ClickHouseSink{
		config:    config,
		buffer:    make([]domain.CorrelatedLog, 0, config.BatchSize),
		flushChan: make(chan struct{}, 1),
		done:      make(chan struct{}),
		logger:    observability.NewLogger("clickhouse"),
	}

	// Parse DSN and create options
	options, err := clickhouse.ParseDSN(config.DSN)
	if err != nil {
		return nil, fmt.Errorf("failed to parse ClickHouse DSN: %w", err)
	}

	// Connect to ClickHouse using native API
	conn, err := clickhouse.Open(options)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to ClickHouse: %w", err)
	}

	// Ping with timeout to verify connection
	pingCtx, pingCancel := context.WithTimeout(context.Background(), time.Duration(DefaultPingTimeoutMs)*time.Millisecond)
	defer pingCancel()

	if err := conn.Ping(pingCtx); err != nil {
		_ = conn.Close()
		return nil, fmt.Errorf("failed to ping ClickHouse: %w", err)
	}

	s.conn = conn
	s.log().Infof("connected to ClickHouse: table=%s batch_size=%d flush_interval_ms=%d",
		config.Table, config.BatchSize, config.FlushIntervalMs)

	// Start flush goroutine
	s.wg.Add(1)
	go s.flushLoop()

	return s, nil
}

// Name returns the sink name.
func (s *ClickHouseSink) Name() string {
	return "clickhouse"
}

// log returns the logger, initializing a default one if not set (e.g. in tests).
func (s *ClickHouseSink) log() *observability.Logger {
	if s.logger == nil {
		s.logger = observability.NewLogger("clickhouse")
	}
	return s.logger
}

// Reopen is a no-op for ClickHouse (connection is managed internally).
func (s *ClickHouseSink) Reopen() error {
	return nil
}

// Write adds a log to the buffer.
func (s *ClickHouseSink) Write(ctx context.Context, log domain.CorrelatedLog) error {
	deadline := time.Now().Add(time.Duration(s.config.TimeoutMs) * time.Millisecond)

	for {
		s.mu.Lock()
		if len(s.buffer) < s.config.MaxBufferSize {
			s.buffer = append(s.buffer, log)
			if len(s.buffer) >= s.config.BatchSize {
				select {
				case s.flushChan <- struct{}{}:
				default:
				}
			}
			s.mu.Unlock()
			return nil
		}
		drop := s.config.DropOnOverflow
		s.mu.Unlock()

		if drop {
			s.log().Warnf("buffer full, dropping log: table=%s buffer_size=%d", s.config.Table, s.config.MaxBufferSize)
			return nil
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("buffer full, timeout exceeded")
		}

		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(10 * time.Millisecond):
		}
	}
}

// Flush flushes the buffer to ClickHouse.
func (s *ClickHouseSink) Flush(ctx context.Context) error {
	return s.doFlush(ctx)
}

// Close closes the sink.
func (s *ClickHouseSink) Close() error {
	var closeErr error

	s.closeOnce.Do(func() {
		if s.done != nil {
			close(s.done)
		}
		s.wg.Wait()

		flushCtx, cancel := context.WithTimeout(context.Background(), time.Duration(s.config.TimeoutMs)*time.Millisecond)
		defer cancel()
		if err := s.doFlush(flushCtx); err != nil {
			closeErr = err
		}

		if s.conn != nil {
			if err := s.conn.Close(); err != nil && closeErr == nil {
				closeErr = err
			}
		}
	})

	return closeErr
}

func (s *ClickHouseSink) flushLoop() {
	defer s.wg.Done()

	ticker := time.NewTicker(time.Duration(s.config.FlushIntervalMs) * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-s.done:
			ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.config.TimeoutMs)*time.Millisecond)
			if err := s.doFlush(ctx); err != nil {
				s.log().Error("final flush on close failed", err)
			}
			cancel()
			return

		case <-ticker.C:
			s.mu.Lock()
			needsFlush := len(s.buffer) > 0
			s.mu.Unlock()

			if needsFlush {
				ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.config.TimeoutMs)*time.Millisecond)
				if err := s.doFlush(ctx); err != nil {
					s.log().Error("periodic flush failed", err)
				}
				cancel()
			}

		case <-s.flushChan:
			s.mu.Lock()
			needsFlush := len(s.buffer) >= s.config.BatchSize
			s.mu.Unlock()

			if needsFlush {
				ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.config.TimeoutMs)*time.Millisecond)
				if err := s.doFlush(ctx); err != nil {
					s.log().Error("batch flush failed", err)
				}
				cancel()
			}
		}
	}
}

func (s *ClickHouseSink) doFlush(ctx context.Context) error {
	s.mu.Lock()
	if len(s.buffer) == 0 {
		s.mu.Unlock()
		return nil
	}

	// Copy buffer to flush
	buffer := make([]domain.CorrelatedLog, len(s.buffer))
	copy(buffer, s.buffer)
	s.buffer = make([]domain.CorrelatedLog, 0, s.config.BatchSize)
	s.mu.Unlock()

	if s.conn == nil {
		return fmt.Errorf("clickhouse connection is not initialized")
	}

	batchSize := len(buffer)

	// Retry logic with exponential backoff
	var lastErr error
	for attempt := 0; attempt < MaxRetries; attempt++ {
		if attempt > 0 {
			delay := RetryBaseDelay * time.Duration(1<<uint(attempt-1))
			s.log().Warnf("retrying batch insert: attempt=%d/%d delay=%s rows=%d err=%v",
				attempt+1, MaxRetries, delay, batchSize, lastErr)
			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return ctx.Err()
			}
		}

		lastErr = s.executeBatch(ctx, buffer)
		if lastErr == nil {
			s.log().Debugf("batch sent: rows=%d table=%s", batchSize, s.config.Table)
			return nil
		}

		if !isRetryableError(lastErr) {
			return fmt.Errorf("non-retryable error: %w", lastErr)
		}
	}

	return fmt.Errorf("failed after %d retries (batch size: %d): %w", MaxRetries, batchSize, lastErr)
}

func (s *ClickHouseSink) executeBatch(ctx context.Context, buffer []domain.CorrelatedLog) error {
	if s.conn == nil {
		return fmt.Errorf("clickhouse connection is not initialized")
	}

	// Table schema: http_logs_raw (raw_json String)
	// Single column insert - the entire log is serialized as JSON string
	query := fmt.Sprintf(`INSERT INTO %s (raw_json)`, s.config.Table)

	// Prepare batch using native clickhouse-go/v2 API
	batch, err := s.conn.PrepareBatch(ctx, query)
	if err != nil {
		return fmt.Errorf("failed to prepare batch: %w", err)
	}

	for i, log := range buffer {
		// Marshal the entire CorrelatedLog to JSON
		logJSON, marshalErr := json.Marshal(log)
		if marshalErr != nil {
			return fmt.Errorf("failed to marshal log %d to JSON: %w", i, marshalErr)
		}

		// Append the JSON string as the raw_json column value
		appendErr := batch.Append(string(logJSON))
		if appendErr != nil {
			return fmt.Errorf("failed to append log %d to batch: %w", i, appendErr)
		}
	}

	// Send the batch - DO NOT FORGET this step
	sendErr := batch.Send()
	if sendErr != nil {
		return fmt.Errorf("failed to send batch (%d rows): %w", len(buffer), sendErr)
	}

	return nil
}

// isRetryableError checks if an error is retryable.
func isRetryableError(err error) bool {
	if err == nil {
		return false
	}

	if errors.Is(err, context.DeadlineExceeded) {
		return true
	}

	if errors.Is(err, context.Canceled) {
		return false
	}

	var netErr net.Error
	if errors.As(err, &netErr) {
		if netErr.Timeout() {
			return true
		}
	}

	errStr := strings.ToLower(err.Error())

	// Explicit non-retryable SQL/schema errors
	if strings.Contains(errStr, "syntax error") ||
		strings.Contains(errStr, "unknown table") ||
		strings.Contains(errStr, "unknown column") ||
		(strings.Contains(errStr, "table") && strings.Contains(errStr, "not found")) {
		return false
	}

	// Fallback network/transient errors
	retryableErrors := []string{
		"connection refused",
		"connection reset",
		"timeout",
		"temporary failure",
		"network is unreachable",
		"broken pipe",
		"no route to host",
	}
	for _, re := range retryableErrors {
		if strings.Contains(errStr, re) {
			return true
		}
	}

	return false
}
