// maintenance_dispatch.go bridges the pure-logic maintenance.Ticker
// (internal/server/maintenance) to the side-effecting world: checks
// online state, builds the per-kind command.run payload, and calls
// dispatchJobWithPayload — the same path operator-triggered Run-now
// uses. Cadence-driven jobs are persisted with actor_kind="system"
// (dispatchJobWithPayload tags it that way when user==nil).
//
// Maintenance fires deliberately do NOT queue to pending_runs when
// the host is offline — five missed prunes on a laptop returning
// from a week away is not what the operator wants. Skip + log; the
// next 60s tick will re-evaluate.
package http

import (
	"context"
	"errors"
	"log/slog"
	"strconv"

	"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance"
	"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)

// DispatchMaintenance acts on each Decision from the ticker. Offline
// hosts are skipped (logged); prune dispatches without admin creds
// are skipped silently (logged) — the operator hasn't completed the
// admin-creds setup yet, and re-trying every minute would just spam
// the logs. (Operator-triggered prune via the run-now endpoint
// returns a clear error instead — different path, different UX.)
func (s *Server) DispatchMaintenance(ctx context.Context, decisions []maintenance.Decision) {
	for _, d := range decisions {
		if !s.deps.Hub.Connected(d.HostID) {
			slog.Info("maintenance: host offline, skipping",
				"host_id", d.HostID, "kind", d.Kind)
			continue
		}
		switch d.Kind {
		case "forget":
			payload, ok, err := s.buildForgetPayloadForHost(ctx, d.HostID)
			if err != nil {
				slog.Warn("maintenance: list source groups failed",
					"host_id", d.HostID, "err", err)
				continue
			}
			if !ok {
				slog.Info("maintenance: forget skipped — no source groups with retention",
					"host_id", d.HostID)
				continue
			}
			_, _, code, msg := s.dispatchJobWithPayload(ctx, nil, d.HostID, api.JobForget, nil, payload)
			if code != "" {
				slog.Warn("maintenance: forget dispatch failed",
					"host_id", d.HostID, "code", code, "msg", msg)
			}
		case "prune":
			if _, err := s.deps.Store.GetHostCredentials(ctx, d.HostID, store.CredKindAdmin); err != nil {
				if errors.Is(err, store.ErrNotFound) {
					slog.Info("maintenance: prune skipped — no admin creds",
						"host_id", d.HostID)
					continue
				}
				slog.Warn("maintenance: prune skipped — admin creds error",
					"host_id", d.HostID, "err", err)
				continue
			}
			if err := s.pushAdminCredsToAgent(ctx, d.HostID); err != nil {
				slog.Warn("maintenance: prune push admin creds failed",
					"host_id", d.HostID, "err", err)
				continue
			}
			payload := api.CommandRunPayload{RequiresAdminCreds: true}
			_, _, code, msg := s.dispatchJobWithPayload(ctx, nil, d.HostID, api.JobPrune, nil, payload)
			if code != "" {
				slog.Warn("maintenance: prune dispatch failed",
					"host_id", d.HostID, "code", code, "msg", msg)
			}
		case "check":
			payload := api.CommandRunPayload{Args: []string{strconv.Itoa(d.SubsetPct)}}
			_, _, code, msg := s.dispatchJobWithPayload(ctx, nil, d.HostID, api.JobCheck, nil, payload)
			if code != "" {
				slog.Warn("maintenance: check dispatch failed",
					"host_id", d.HostID, "code", code, "msg", msg)
			}
		default:
			slog.Warn("maintenance: unknown decision kind",
				"host_id", d.HostID, "kind", d.Kind)
		}
	}
}

// buildForgetPayloadForHost collects every source group on the host
// that has a non-empty retention policy and builds a CommandRunPayload
// with ForgetGroups populated. Returns ok=false if the host has no
// such groups (the dispatcher then skips this kind).
func (s *Server) buildForgetPayloadForHost(ctx context.Context, hostID string) (api.CommandRunPayload, bool, error) {
	groups, err := s.deps.Store.ListSourceGroupsByHost(ctx, hostID)
	if err != nil {
		return api.CommandRunPayload{}, false, err
	}
	fg := make([]api.ForgetGroup, 0, len(groups))
	for _, g := range groups {
		if isEmptyRetention(g.RetentionPolicy) {
			continue
		}
		fg = append(fg, api.ForgetGroup{
			Tag:    g.Name,
			Policy: forgetPolicyJSONFromStore(g.RetentionPolicy),
		})
	}
	if len(fg) == 0 {
		return api.CommandRunPayload{}, false, nil
	}
	return api.CommandRunPayload{ForgetGroups: fg}, true, nil
}

func isEmptyRetention(p store.RetentionPolicy) bool {
	return p.KeepLast == nil && p.KeepHourly == nil &&
		p.KeepDaily == nil && p.KeepWeekly == nil &&
		p.KeepMonthly == nil && p.KeepYearly == nil
}

// forgetPolicyJSONFromStore copies retention pointers from the store
// view to the wire view. Both shapes are field-for-field identical;
// this avoids importing store from internal/api (which would invert
// the dependency direction).
func forgetPolicyJSONFromStore(p store.RetentionPolicy) api.ForgetPolicyJSON {
	return api.ForgetPolicyJSON{
		KeepLast:    p.KeepLast,
		KeepHourly:  p.KeepHourly,
		KeepDaily:   p.KeepDaily,
		KeepWeekly:  p.KeepWeekly,
		KeepMonthly: p.KeepMonthly,
		KeepYearly:  p.KeepYearly,
	}
}
