package web

import (
	"context"
	"strconv"
	"sync"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
)

// Disk-health card + 6-hourly degradation check (v0.169.0). The card and the check share ONE data
// path (the 60s-TTL-cached /disks call) and ONE verdict function (agentapi.DiskVerdictFor), so the
// chip a customer sees and the alert they receive can never disagree. No new smartctl load — the
// agent (v0.94.0) serializes its already-computed SMART; this consumes it and feature-detects
// (nil Smart → "Nincs adat", never alarms). No global banner (CONTEXT ruling) — card + email only.

const diskCacheTTL = 60 * time.Second

type diskHealthState struct {
	mu        sync.Mutex
	cacheAt   time.Time
	cacheResp agentapi.DisksResponse
	cacheErr  error
	cacheSet  bool
	// baseline is the last-seen verdict per disk (in-memory only). UNKNOWN is never recorded. Lost on
	// restart → the next check re-baselines silently (accepted; see CONTEXT).
	baseline  map[string]agentapi.DiskVerdict
	baselined bool
}

// DiskHealthRow is one rendered card row.
type DiskHealthRow struct {
	Label     string
	ChipLabel string // "Rendben" | "Figyelmeztetés" | "Hiba" | "Nincs adat"
	ChipClass string // design-system state-text-* color
	Temp      string // e.g. "34" — empty when the device reports no temperature
}

// cachedDisks fetches /disks through a 60s in-process TTL cache so dashboard refresh-spam cannot
// smartctl-storm the host (the agent recomputes SMART per /disks call). disksFn is the test seam
// (nil → the real agent client). Never blocks the page: the caller treats an error as "Nincs adat".
func (s *Server) cachedDisks(ctx context.Context) (agentapi.DisksResponse, error) {
	s.diskHealth.mu.Lock()
	defer s.diskHealth.mu.Unlock()
	if s.diskHealth.cacheSet && time.Since(s.diskHealth.cacheAt) < diskCacheTTL {
		return s.diskHealth.cacheResp, s.diskHealth.cacheErr
	}
	resp, err := s.fetchDisks(ctx)
	s.diskHealth.cacheResp, s.diskHealth.cacheErr, s.diskHealth.cacheAt, s.diskHealth.cacheSet = resp, err, time.Now(), true
	return resp, err
}

func (s *Server) fetchDisks(ctx context.Context) (agentapi.DisksResponse, error) {
	if s.disksFn != nil {
		return s.disksFn(ctx)
	}
	client, err := s.agentClient()
	if err != nil {
		return agentapi.DisksResponse{}, err
	}
	return client.Disks(ctx)
}

// isPhysicalDisk reports whether a target is a physical disk SMART applies to. Logical/network
// storage (PBS, LVM-thin, NFS, CIFS) is excluded even though the agent may default its SMART to
// UNKNOWN — those are storage abstractions, not disks, and must not clutter the disk-health card.
func isPhysicalDisk(d agentapi.DiskInfo) bool {
	switch d.Type {
	case "pbs", "lvmthin", "nfs", "cifs":
		return false
	}
	return d.BackingDevice != "" || d.Smart != nil
}

// diskDisplayLabel is the customer-facing disk label (PVE name + a Hungarian speed hint when known).
func diskDisplayLabel(d agentapi.DiskInfo) string {
	// Prefer the device model (agent v0.95.0) over the raw storage name/UUID — a customer reads
	// "TOSHIBA MQ04ABF100", not "47a3361a-…". Falls back to Name (+ speed hint) on an older agent.
	if d.Smart != nil && d.Smart.ModelName != nil && *d.Smart.ModelName != "" {
		return *d.Smart.ModelName
	}
	switch d.Class {
	case "fast":
		return d.Name + " (gyors)"
	case "slow":
		return d.Name + " (lassú)"
	default:
		return d.Name
	}
}

func diskChipClass(v agentapi.DiskVerdict) string {
	switch v {
	case agentapi.DiskVerdictOK:
		return "state-text-run"
	case agentapi.DiskVerdictWarn:
		return "state-text-warn"
	case agentapi.DiskVerdictFail:
		return "state-text-crit"
	default:
		return "state-text-neutral"
	}
}

// diskHealthRows builds the "Lemezek állapota" card rows for the physical disks. Never errors: an
// unreachable agent yields nil and the card renders its empty state.
func (s *Server) diskHealthRows(ctx context.Context) []DiskHealthRow {
	resp, err := s.cachedDisks(ctx)
	if err != nil {
		return nil
	}
	var rows []DiskHealthRow
	for _, d := range resp.Disks {
		if !isPhysicalDisk(d) {
			continue
		}
		v := agentapi.DiskVerdictFor(d.Smart)
		row := DiskHealthRow{Label: diskDisplayLabel(d), ChipLabel: v.Label(), ChipClass: diskChipClass(v)}
		if d.Smart != nil && d.Smart.TemperatureC != nil {
			row.Temp = strconv.Itoa(*d.Smart.TemperatureC)
		}
		rows = append(rows, row)
	}
	return rows
}

// diskKey is a disk's stable identity across checks (durable id preferred; falls back to name).
func diskKey(d agentapi.DiskInfo) string {
	if d.DurableID != "" {
		return d.DurableID
	}
	if d.WipeDurableID != "" {
		return d.WipeDurableID
	}
	return "name:" + d.Name
}

// RunDiskHealthCheck is the 6-hourly job. It emits disk_health_degraded ONLY on a degradation
// transition (a disk's verdict WORSENED) against the in-memory baseline. UNKNOWN is excluded both
// directions (never recorded, never a transition endpoint). The FIRST run baselines silently; a
// newly-appeared disk baselines silently; recovery (improvement) notifies nothing. Returns nil even
// when the agent is unreachable (skip quietly — no baseline churn, no alarm).
func (s *Server) RunDiskHealthCheck(ctx context.Context) error {
	// Fetch FRESH (not the 60s card cache): the check runs every 6h, so it must see current SMART, and
	// this keeps its transition logic independent of dashboard render timing.
	resp, err := s.fetchDisks(ctx)
	if err != nil {
		return nil
	}

	type degradation struct {
		label    string
		attrs    []string
		critical bool
	}
	var fired []degradation

	s.diskHealth.mu.Lock()
	if s.diskHealth.baseline == nil {
		s.diskHealth.baseline = map[string]agentapi.DiskVerdict{}
	}
	firstRun := !s.diskHealth.baselined
	seen := map[string]bool{}
	for _, d := range resp.Disks {
		if !isPhysicalDisk(d) {
			continue
		}
		key := diskKey(d)
		seen[key] = true
		v := agentapi.DiskVerdictFor(d.Smart)
		if v == agentapi.DiskVerdictUnknown {
			// Excluded both directions: don't record, don't transition, don't drop an existing baseline
			// (a transient UNKNOWN blip must not erase history or fire).
			continue
		}
		prev, had := s.diskHealth.baseline[key]
		s.diskHealth.baseline[key] = v
		if firstRun || !had {
			continue // first verdict ever for this disk → baseline silently
		}
		if v > prev { // verdict worsened (Unknown=0 < OK=1 < Warn=2 < Fail=3; Unknown excluded above)
			fired = append(fired, degradation{
				label:    diskDisplayLabel(d),
				attrs:    agentapi.DegradedAttributes(d.Smart),
				critical: v == agentapi.DiskVerdictFail,
			})
		}
	}
	// Forget disks no longer reported so a reappearance re-baselines silently.
	for k := range s.diskHealth.baseline {
		if !seen[k] {
			delete(s.diskHealth.baseline, k)
		}
	}
	s.diskHealth.baselined = true
	s.diskHealth.mu.Unlock()

	for _, f := range fired {
		s.emitDiskDegraded(f.label, f.attrs, f.critical)
	}
	return nil
}

// emitDiskDegraded routes a degradation to the notifier (or the test seam). nil notifier → no-op.
func (s *Server) emitDiskDegraded(label string, attrs []string, critical bool) {
	if s.diskNotifyFn != nil {
		s.diskNotifyFn(label, attrs, critical)
		return
	}
	if s.notifier != nil {
		s.notifier.NotifyDiskHealthDegraded(label, attrs, critical)
	}
}
