package main

import (
	"context"
	"flag"
	"fmt"
	"log"
	"math"
	"os"
	"strings"
	"time"

	"gitlab.com/mbugroup/lti-api.git/internal/config"
	"gitlab.com/mbugroup/lti-api.git/internal/database"
	recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
	"gorm.io/gorm"
)

const metricEpsilon = 1e-9

type normalizeOptions struct {
	Apply                 bool
	RecordingID           uint
	ProjectFlockKandangID uint
	From                  *time.Time
	To                    *time.Time
	BatchSize             int
	Limit                 int
}

type normalizeStats struct {
	Processed int
	Changed   int
	Updated   int
	Skipped   int
	Failed    int
}

type recordingMetricRow struct {
	ID                    uint      `gorm:"column:id"`
	ProjectFlockKandangID uint      `gorm:"column:project_flock_kandangs_id"`
	RecordDatetime        time.Time `gorm:"column:record_datetime"`
	HenHouse              *float64  `gorm:"column:hen_house"`
	EggMass               *float64  `gorm:"column:egg_mass"`
}

func main() {
	var (
		apply                 bool
		recordingID           uint
		projectFlockKandangID uint
		fromRaw               string
		toRaw                 string
		batchSize             int
		limit                 int
	)

	flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
	flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
	flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
	flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
	flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
	flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
	flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
	flag.Parse()

	if batchSize <= 0 {
		log.Fatal("--batch-size must be > 0")
	}
	if limit < 0 {
		log.Fatal("--limit cannot be negative")
	}

	from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
	if err != nil {
		log.Fatalf("invalid --from: %v", err)
	}
	to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
	if err != nil {
		log.Fatalf("invalid --to: %v", err)
	}
	if from != nil && to != nil && to.Before(*from) {
		log.Fatal("--to cannot be before --from")
	}

	opts := normalizeOptions{
		Apply:                 apply,
		RecordingID:           recordingID,
		ProjectFlockKandangID: projectFlockKandangID,
		From:                  from,
		To:                    to,
		BatchSize:             batchSize,
		Limit:                 limit,
	}

	ctx := context.Background()
	db := database.Connect(config.DBHost, config.DBName)
	repo := recordingRepo.NewRecordingRepository(db)

	fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
	fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
	fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
	fmt.Printf("Filter from: %s\n", displayTime(opts.From))
	fmt.Printf("Filter to: %s\n", displayTime(opts.To))
	fmt.Printf("Batch size: %d\n", opts.BatchSize)
	fmt.Printf("Limit: %d\n\n", opts.Limit)

	stats, err := normalizeRecordings(ctx, db, repo, opts)
	if err != nil {
		log.Fatalf("normalize failed: %v", err)
	}

	fmt.Println()
	fmt.Printf(
		"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
		stats.Processed,
		stats.Changed,
		stats.Updated,
		stats.Skipped,
		stats.Failed,
	)

	if stats.Failed > 0 {
		os.Exit(1)
	}
}

func normalizeRecordings(
	ctx context.Context,
	db *gorm.DB,
	repo recordingRepo.RecordingRepository,
	opts normalizeOptions,
) (normalizeStats, error) {
	stats := normalizeStats{}
	lastID := uint(0)
	initialChickCache := make(map[uint]float64)

	for {
		batchLimit := opts.BatchSize
		if opts.Limit > 0 {
			remaining := opts.Limit - stats.Processed
			if remaining <= 0 {
				break
			}
			if remaining < batchLimit {
				batchLimit = remaining
			}
		}

		rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
		if err != nil {
			return stats, err
		}
		if len(rows) == 0 {
			break
		}

		for _, row := range rows {
			stats.Processed++
			lastID = row.ID

			initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
			if !ok {
				initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
				if err != nil {
					fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
					stats.Failed++
					continue
				}
				initialChickCache[row.ProjectFlockKandangID] = initialChick
			}

			_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
			if err != nil {
				fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
				stats.Failed++
				continue
			}

			cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
			if err != nil {
				fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
				stats.Failed++
				continue
			}

			newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
			henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
			eggMassChanged := metricChanged(row.EggMass, newEggMass)

			if !henHouseChanged && !eggMassChanged {
				stats.Skipped++
				continue
			}

			stats.Changed++
			fmt.Printf(
				"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
				row.ID,
				row.ProjectFlockKandangID,
				row.RecordDatetime.UTC().Format(time.RFC3339),
				displayFloat(row.HenHouse),
				displayFloat(newHenHouse),
				displayFloat(row.EggMass),
				displayFloat(newEggMass),
			)

			if !opts.Apply {
				continue
			}

			if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
				fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
				stats.Failed++
				continue
			}

			fmt.Printf(
				"DONE rec=%d hen_house=%s egg_mass=%s\n",
				row.ID,
				displayFloat(newHenHouse),
				displayFloat(newEggMass),
			)
			stats.Updated++
		}

		if opts.RecordingID > 0 {
			break
		}
	}

	return stats, nil
}

func loadRecordingBatch(
	ctx context.Context,
	db *gorm.DB,
	opts normalizeOptions,
	lastID uint,
	limit int,
) ([]recordingMetricRow, error) {
	query := db.WithContext(ctx).
		Table("recordings").
		Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
		Where("recordings.deleted_at IS NULL")

	if opts.RecordingID > 0 {
		query = query.Where("recordings.id = ?", opts.RecordingID)
	}
	if opts.ProjectFlockKandangID > 0 {
		query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
	}
	if opts.From != nil {
		query = query.Where("recordings.record_datetime >= ?", *opts.From)
	}
	if opts.To != nil {
		query = query.Where("recordings.record_datetime <= ?", *opts.To)
	}
	if opts.RecordingID == 0 && lastID > 0 {
		query = query.Where("recordings.id > ?", lastID)
	}

	var rows []recordingMetricRow
	err := query.
		Order("recordings.id ASC").
		Limit(limit).
		Scan(&rows).Error
	return rows, err
}

func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
	var henHouse *float64
	if initialChick > 0 && cumulativeEggQty >= 0 {
		value := cumulativeEggQty / initialChick
		henHouse = &value
	}

	var eggMass *float64
	if initialChick > 0 && totalEggWeightGrams > 0 {
		value := totalEggWeightGrams / initialChick
		eggMass = &value
	}

	return henHouse, eggMass
}

func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
	updates := map[string]any{}
	if henHouse == nil {
		updates["hen_house"] = gorm.Expr("NULL")
	} else {
		updates["hen_house"] = *henHouse
	}
	if eggMass == nil {
		updates["egg_mass"] = gorm.Expr("NULL")
	} else {
		updates["egg_mass"] = *eggMass
	}

	return db.WithContext(ctx).
		Table("recordings").
		Where("id = ?", recordingID).
		Updates(updates).Error
}

func metricChanged(oldValue, newValue *float64) bool {
	if oldValue == nil && newValue == nil {
		return false
	}
	if oldValue == nil || newValue == nil {
		return true
	}
	return !nearlyEqual(*oldValue, *newValue)
}

func nearlyEqual(a, b float64) bool {
	scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
	return math.Abs(a-b) <= metricEpsilon*scale
}

func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
	if raw == "" {
		return nil, nil
	}

	layouts := []string{
		time.RFC3339Nano,
		time.RFC3339,
		"2006-01-02 15:04:05",
		"2006-01-02",
	}

	for _, layout := range layouts {
		parsed, err := time.Parse(layout, raw)
		if err != nil {
			continue
		}
		if layout == "2006-01-02" {
			if isUpper {
				endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
				return &endOfDay, nil
			}
			startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
			return &startOfDay, nil
		}

		t := parsed.UTC()
		return &t, nil
	}

	return nil, fmt.Errorf("unsupported format %q", raw)
}

func modeLabel(apply bool) string {
	if apply {
		return "APPLY"
	}
	return "DRY-RUN"
}

func displayFloat(v *float64) string {
	if v == nil {
		return "NULL"
	}
	return fmt.Sprintf("%.6f", *v)
}

func displayTime(v *time.Time) string {
	if v == nil {
		return "<nil>"
	}
	return v.UTC().Format(time.RFC3339)
}

func displayUint(v uint) string {
	if v == 0 {
		return "<all>"
	}
	return fmt.Sprintf("%d", v)
}
