package intelligence

import (
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"time"

	"github.com/rs/zerolog"
)

// Recommendation is the structured suggestion emitted by the engine.
type Recommendation struct {
	Type           string         `json:"type"`
	Subject        string         `json:"subject"` // free-form: tracker id, info_hash, "disk", ...
	SubjectType    string         `json:"subject_type"`
	Title          string         `json:"title"`
	Body           string         `json:"body"`
	Confidence     float64        `json:"confidence"`
	Provenance     Provenance     `json:"provenance"`
}

// Engine orchestrates the individual metric modules. Phase 7 ships three
// recommendation types — Phase 13's bandit will add tighten/relax variants.
type Engine struct {
	db     *sql.DB
	logger zerolog.Logger

	// Defaults the operator can override per call. Phase 10's health
	// budgets will surface these to operators directly.
	DeadSwarmThresholdHours float64
	HRRiskWithinHours       float64
	HRDefaultRequiredSecs   int64
	DiskCapacityBytes       int64
	DiskCurrentBytes        int64
}

// NewEngine wraps a *sql.DB.
func NewEngine(db *sql.DB, logger zerolog.Logger) *Engine {
	return &Engine{
		db:                      db,
		logger:                  logger.With().Str("component", "recommendations").Logger(),
		DeadSwarmThresholdHours: 72,
		HRRiskWithinHours:       24,
		HRDefaultRequiredSecs:   432000, // 120h
	}
}

// Generate runs the full pipeline once. Returns recommendations sorted by
// confidence descending. Callers are expected to write them to decision_log
// (see internal/decisions); the recommendations themselves are stateless.
func (e *Engine) Generate(ctx context.Context) ([]Recommendation, error) {
	now := time.Now()

	// Run modules in parallel-friendly order; SQLite serializes the
	// underlying connection but the queries are cheap.
	dead, derr := Find(ctx, e.db, e.DeadSwarmThresholdHours)
	if derr != nil {
		e.logger.Warn().Err(derr).Msg("dead_swarms failed")
	}
	risk, rerr := FindAtRisk(ctx, e.db, e.HRRiskWithinHours, e.HRDefaultRequiredSecs)
	if rerr != nil {
		e.logger.Warn().Err(rerr).Msg("hr_risk failed")
	}
	vels, verr := ComputeAll(ctx, e.db, 7*24*time.Hour)
	if verr != nil {
		e.logger.Warn().Err(verr).Msg("ratio_velocity failed")
	}

	atRiskHashes := map[string]struct{}{}
	for _, r := range risk {
		atRiskHashes[r.InfoHash] = struct{}{}
	}

	var out []Recommendation

	// Recommendation 1: delete dead swarms (excluding torrents currently at HR risk).
	safeToDelete := make([]DeadSwarm, 0, len(dead))
	var totalRecoverable int64
	for _, d := range dead {
		if _, atRisk := atRiskHashes[d.InfoHash]; atRisk {
			continue
		}
		safeToDelete = append(safeToDelete, d)
		totalRecoverable += d.SizeBytes
	}
	if len(safeToDelete) > 0 {
		hashes := make([]string, 0, len(safeToDelete))
		for _, d := range safeToDelete {
			hashes = append(hashes, d.InfoHash)
		}
		out = append(out, Recommendation{
			Type:        "delete_dead_swarms",
			Subject:     fmt.Sprintf("%d torrents", len(safeToDelete)),
			SubjectType: "system",
			Title:       fmt.Sprintf("Delete %d dead-swarm torrents", len(safeToDelete)),
			Body: fmt.Sprintf(
				"%d torrents have been seeded for ≥%.0fh with no upload activity. Total %.2f GB recoverable. HR-at-risk torrents are excluded from this list.",
				len(safeToDelete), e.DeadSwarmThresholdHours, float64(totalRecoverable)/(1<<30)),
			Confidence: clamp01(0.6 + 0.05*float64(len(safeToDelete))),
			Provenance: Provenance{
				GeneratedAt: now,
				Inputs: []Input{
					{Source: "torrent_snapshots", Filter: "no_upload_in_window", RowCount: len(safeToDelete), WindowDur: time.Duration(e.DeadSwarmThresholdHours) * time.Hour},
					{Source: "torrent_trackers", Filter: "hr_at_risk excluded", RowCount: len(atRiskHashes)},
				},
				Rules: []FiredRule{
					{
						Name:        "dead_swarm_threshold",
						Description: "Torrent has been seeded for ≥ threshold hours with zero upload activity over the same window.",
						Values:      map[string]any{"threshold_hours": e.DeadSwarmThresholdHours, "matched": len(dead), "safe_after_hr_filter": len(safeToDelete)},
					},
				},
				Alternatives: []Alternative{
					{Action: "wait", Reason: "Operator may prefer to keep dead swarms until disk fills."},
				},
				Assumptions: map[string]any{
					"dead_swarm_threshold_hours": e.DeadSwarmThresholdHours,
					"hr_safety_margin_hours":     e.HRRiskWithinHours,
				},
			},
		})
		_ = hashes // referenced for future "apply" path (Phase 7 brief deliverable)
	}

	// Recommendation 2: HR risk imminent.
	if len(risk) > 0 {
		out = append(out, Recommendation{
			Type:        "hr_risk_imminent",
			Subject:     fmt.Sprintf("%d torrents", len(risk)),
			SubjectType: "system",
			Title:       fmt.Sprintf("HR risk: %d torrents within %.0fh", len(risk), e.HRRiskWithinHours),
			Body: fmt.Sprintf(
				"%d torrents are below their tracker's seed-time requirement and within the configured warning window. Keep seeding or move to a tracker that accepts the loss.",
				len(risk)),
			Confidence: 0.85,
			Provenance: Provenance{
				GeneratedAt: now,
				Inputs: []Input{
					{Source: "torrent_trackers", Filter: "seed_time_remaining < within_hours", RowCount: len(risk)},
				},
				Rules: []FiredRule{
					{
						Name:        "hr_within_window",
						Description: "Time until hit-and-run window expires is below configured threshold.",
						Values:      map[string]any{"within_hours": e.HRRiskWithinHours, "default_required_seconds": e.HRDefaultRequiredSecs, "matched": len(risk)},
					},
				},
				Assumptions: map[string]any{
					"default_required_seconds": e.HRDefaultRequiredSecs,
				},
			},
		})
	}

	// Recommendation 3: ratio falling alert (informational; severity scaled
	// by slope magnitude).
	for _, v := range vels {
		if v.Trend != "falling" || v.SlopePerDay > -0.05 {
			continue
		}
		out = append(out, Recommendation{
			Type:        "ratio_falling",
			Subject:     v.TrackerID,
			SubjectType: "tracker",
			Title:       fmt.Sprintf("Ratio dropping fast on %s", v.TrackerID),
			Body: fmt.Sprintf(
				"Ratio fell %.3f/day over the last week (from %.3f to %.3f). Consider tightening filter grab volume on this tracker.",
				v.SlopePerDay, v.StartRatio, v.EndRatio),
			Confidence: clamp01(-v.SlopePerDay * 2.0),
			Provenance: Provenance{
				GeneratedAt: now,
				Inputs: []Input{
					{Source: "ratio_snapshots", Filter: fmt.Sprintf("tracker_id=%s", v.TrackerID), RowCount: v.SampleCount, WindowDur: 7 * 24 * time.Hour},
				},
				Rules: []FiredRule{
					{
						Name:        "ratio_falling_fast",
						Description: "Per-day ratio slope is below the threshold (negative).",
						Values:      map[string]any{"slope_per_day": v.SlopePerDay, "threshold": -0.05},
					},
				},
			},
		})
	}

	// Sort by confidence descending.
	for i := 0; i < len(out); i++ {
		for j := i + 1; j < len(out); j++ {
			if out[j].Confidence > out[i].Confidence {
				out[i], out[j] = out[j], out[i]
			}
		}
	}
	return out, nil
}

// ToJSON renders the provenance into the JSON shape stored in
// decision_log.provenance_json.
func (r Recommendation) ToJSON() (string, string, error) {
	prov, err := json.Marshal(r.Provenance)
	if err != nil {
		return "", "", err
	}
	alts, err := json.Marshal(r.Provenance.Alternatives)
	if err != nil {
		return "", "", err
	}
	return string(prov), string(alts), nil
}

func clamp01(f float64) float64 {
	if f < 0 {
		return 0
	}
	if f > 1 {
		return 1
	}
	return f
}
