package http

import (
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	stdhttp "net/http"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/oklog/ulid/v2"

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

// ui_schedules.go — HTML form-driven schedule CRUD against the slim
// shape (cron + source-group multi-select + enabled).

// hostSchedulesPage backs the list view. GroupNames maps source-group
// ID → name for the per-row tag rendering, populated once on load so
// the template doesn't need to do per-row store lookups.
type hostSchedulesPage struct {
	hostChromeData
	Schedules  []scheduleRow
	GroupNames map[string]string
}

// scheduleRow bundles a schedule with its derived "next run" + "last
// run" data. The Schedule is embedded so existing template field
// references (`$sc.ID`, `$sc.CronExpr`, etc) keep working when we
// switch the iterating slice from []store.Schedule to []scheduleRow.
type scheduleRow struct {
	store.Schedule
	NextRun    *time.Time
	LastRun    *time.Time
	LastJobID  string
	LastStatus string // succeeded|failed|running|queued — empty when never fired
}

// scheduleFormData mirrors the form's wire shape — strings + bool for
// round-trip on validation re-render.
type scheduleFormData struct {
	CronExpr string
	Enabled  bool
}

// scheduleEditPage backs both the new and edit form views.
type scheduleEditPage struct {
	hostChromeData
	IsNew            bool
	ScheduleID       string // empty when IsNew
	Form             scheduleFormData
	AvailableGroups  []store.SourceGroup
	SelectedGroupIDs map[string]bool // gid → checked
	SaveAction       string
	Error            string
}

func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	scheds, err := s.deps.Store.ListSchedulesByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui schedules: list", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui schedules: list groups", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	names := make(map[string]string, len(groups))
	for _, g := range groups {
		names[g.ID] = g.Name
	}

	now := time.Now().UTC()
	rows := make([]scheduleRow, 0, len(scheds))
	for _, sc := range scheds {
		row := scheduleRow{Schedule: sc}
		if sc.Enabled {
			if sched, err := cronParser.Parse(sc.CronExpr); err == nil {
				next := sched.Next(now).UTC()
				row.NextRun = &next
			}
		}
		if j, jerr := s.deps.Store.LatestJobBySchedule(r.Context(), host.ID, sc.ID); jerr == nil && j != nil {
			t := j.CreatedAt
			if j.StartedAt != nil {
				t = *j.StartedAt
			}
			row.LastRun = &t
			row.LastJobID = j.ID
			row.LastStatus = j.Status
		}
		rows = append(rows, row)
	}

	chrome := s.loadHostChrome(r, *host, "schedules", "schedules")
	chrome.ScheduleCount = len(scheds)
	chrome.SourceGroupCount = len(groups)

	view := s.baseView(r, u)
	view.Title = host.Name + " schedules · restic-manager"
	view.Page = hostSchedulesPage{
		hostChromeData: chrome,
		Schedules:      rows,
		GroupNames:     names,
	}
	if err := s.deps.UI.Render(w, "host_schedules", view); err != nil {
		slog.Error("ui: render host_schedules", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui schedule new: list groups", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	view := s.baseView(r, u)
	view.Title = "New schedule · " + host.Name + " · restic-manager"
	view.Page = scheduleEditPage{
		hostChromeData:   s.loadHostChrome(r, *host, "schedules", "new schedule"),
		IsNew:            true,
		Form:             scheduleFormData{Enabled: true},
		AvailableGroups:  groups,
		SelectedGroupIDs: map[string]bool{},
		SaveAction:       "/hosts/" + host.ID + "/schedules/new",
	}
	if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
		slog.Error("ui: render schedule_edit (new)", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	sid := chi.URLParam(r, "sid")
	sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui schedule edit: get", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui schedule edit: list groups", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	selected := make(map[string]bool, len(sc.SourceGroupIDs))
	for _, gid := range sc.SourceGroupIDs {
		selected[gid] = true
	}
	view := s.baseView(r, u)
	view.Title = "Edit schedule · " + host.Name + " · restic-manager"
	view.Page = scheduleEditPage{
		hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"),
		IsNew:          false,
		ScheduleID:     sid,
		Form: scheduleFormData{
			CronExpr: sc.CronExpr,
			Enabled:  sc.Enabled,
		},
		AvailableGroups:  groups,
		SelectedGroupIDs: selected,
		SaveAction:       "/hosts/" + host.ID + "/schedules/" + sid + "/edit",
	}
	if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
		slog.Error("ui: render schedule_edit", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// handleUIScheduleSave handles both create and update. On validation
// error, re-renders with input intact + a banner.
func (s *Server) handleUIScheduleSave(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
		return
	}
	sid := chi.URLParam(r, "sid")
	isNew := sid == ""

	form := scheduleFormData{
		CronExpr: strings.TrimSpace(r.PostForm.Get("cron")),
		Enabled:  r.PostForm.Get("enabled") == "1",
	}
	pickedIDs := r.PostForm["source_group_ids"]
	selected := make(map[string]bool, len(pickedIDs))
	for _, gid := range pickedIDs {
		selected[gid] = true
	}

	// --- validation ---
	var errMsg string
	switch {
	case form.CronExpr == "":
		errMsg = "Cron expression is required."
	case len(pickedIDs) == 0:
		errMsg = "Pick at least one source group — a schedule has to know what to back up."
	}
	if errMsg == "" {
		if _, err := cronParser.Parse(form.CronExpr); err != nil {
			errMsg = "Cron didn't parse: " + err.Error()
		}
	}
	// Verify every picked group belongs to this host.
	if errMsg == "" {
		for _, gid := range pickedIDs {
			g, gerr := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid)
			if gerr != nil || g == nil {
				errMsg = "One of the picked source groups isn't on this host — refresh and try again."
				break
			}
		}
	}

	if errMsg != "" {
		s.renderScheduleFormError(w, r, u, host, sid, isNew, form, selected, errMsg)
		return
	}

	sc := store.Schedule{
		ID:             sid,
		HostID:         host.ID,
		CronExpr:       form.CronExpr,
		Enabled:        form.Enabled,
		SourceGroupIDs: pickedIDs,
	}
	if isNew {
		sc.ID = ulid.Make().String()
		if err := s.deps.Store.CreateSchedule(r.Context(), &sc); err != nil {
			slog.Error("ui schedule save: create", "err", err)
			s.renderScheduleFormError(w, r, u, host, "", true, form, selected,
				"Couldn't create — see the server log for details.")
			return
		}
	} else {
		if err := s.deps.Store.UpdateSchedule(r.Context(), &sc); err != nil {
			slog.Error("ui schedule save: update", "err", err)
			s.renderScheduleFormError(w, r, u, host, sid, false, form, selected,
				"Couldn't save — see the server log for details.")
			return
		}
	}
	s.pushScheduleSetAsync(host.ID)

	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther)
}

func (s *Server) handleUIScheduleDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	sid := chi.URLParam(r, "sid")
	if err := s.deps.Store.DeleteSchedule(r.Context(), host.ID, sid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui schedule delete", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	s.pushScheduleSetAsync(host.ID)
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/schedules", stdhttp.StatusSeeOther)
}

// handleUIScheduleRun is the per-schedule Run-now action: dispatch
// every source group the schedule references in a single shot,
// reusing dispatchScheduledJob (the same path real cron fires take).
// HTMX only — falls back to a 405 for non-HTMX callers (per-group
// Run-now via the Sources tab is the JSON path).
func (s *Server) handleUIScheduleRun(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	if u := s.requireUIUser(w, r); u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	sid := chi.URLParam(r, "sid")
	sc, err := s.deps.Store.GetSchedule(r.Context(), host.ID, sid)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if len(sc.SourceGroupIDs) == 0 {
		stdhttp.Error(w, "this schedule has no source groups attached", stdhttp.StatusConflict)
		return
	}
	if s.deps.Hub == nil {
		stdhttp.Error(w, "ws hub not configured", stdhttp.StatusServiceUnavailable)
		return
	}
	conn := s.deps.Hub.Conn(host.ID)
	if conn == nil {
		stdhttp.Error(w, "host is offline — reconnect the agent and try again",
			stdhttp.StatusConflict)
		return
	}

	// Manual Run-now ignores Enabled. "Disabled" only suppresses
	// cron-tick firing; an ad-hoc one-off run is a separate intent
	// (and the dispatch is audit-logged inside dispatchBackupForGroup).
	// We dispatch inline rather than calling dispatchScheduledJob,
	// which short-circuits on !Enabled.
	ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
	defer cancel()
	now := time.Now().UTC()
	type fired struct{ groupName, jobID string }
	dispatched := make([]fired, 0, len(sc.SourceGroupIDs))
	for _, gid := range sc.SourceGroupIDs {
		g, gerr := s.deps.Store.GetSourceGroup(ctx, host.ID, gid)
		if gerr != nil {
			slog.Warn("ui schedule run: load source group",
				"host_id", host.ID, "schedule_id", sid, "group_id", gid, "err", gerr)
			continue
		}
		jobID := s.dispatchBackupForGroup(ctx, conn, host.ID, sid, g, now)
		if jobID != "" {
			dispatched = append(dispatched, fired{groupName: g.Name, jobID: jobID})
		}
	}

	if wantsHTML(r) {
		switch len(dispatched) {
		case 0:
			stdhttp.Error(w, "no backup jobs dispatched — see server log", stdhttp.StatusInternalServerError)
			return
		case 1:
			// Single-group schedule: jump straight to the live job log,
			// same UX as per-source-group Run-now from the Sources tab.
			w.Header().Set("HX-Redirect", "/jobs/"+dispatched[0].jobID)
		default:
			// Multi-group: stay on the schedules tab and toast the
			// summary. Direct the operator to one of the job logs via
			// the toast (the most recent job ID is fine).
			names := make([]string, 0, len(dispatched))
			for _, f := range dispatched {
				names = append(names, f.groupName)
			}
			msg := strconv.Itoa(len(dispatched)) + " backups dispatched: " + strings.Join(names, ", ")
			payload, _ := json.Marshal(map[string]any{
				"rm:toast": map[string]string{"level": "success", "message": msg},
			})
			w.Header().Set("HX-Trigger", string(payload))
		}
	}
	w.WriteHeader(stdhttp.StatusNoContent)
}

func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, sid string, isNew bool, form scheduleFormData, selected map[string]bool, msg string) {
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui schedule re-render: list groups", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	saveAction := "/hosts/" + host.ID + "/schedules/new"
	crumb := "new schedule"
	if !isNew {
		saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit"
		crumb = "edit schedule"
	}
	view := s.baseView(r, u)
	view.Title = "Schedule · " + host.Name + " · restic-manager"
	view.Page = scheduleEditPage{
		hostChromeData:   s.loadHostChrome(r, *host, "schedules", crumb),
		IsNew:            isNew,
		ScheduleID:       sid,
		Form:             form,
		AvailableGroups:  groups,
		SelectedGroupIDs: selected,
		SaveAction:       saveAction,
		Error:            msg,
	}
	w.WriteHeader(stdhttp.StatusUnprocessableEntity)
	if err := s.deps.UI.Render(w, "schedule_edit", view); err != nil {
		slog.Error("ui: render schedule_edit (error)", "err", err)
	}
}

// loadHostForUI is a small helper shared across the host-detail tab
// handlers — fetches the host by URL param, writing the appropriate
// 404/500 + returning ok=false on failure.
func (s *Server) loadHostForUI(w stdhttp.ResponseWriter, r *stdhttp.Request) (*store.Host, bool) {
	hostID := chi.URLParam(r, "id")
	if hostID == "" {
		stdhttp.NotFound(w, r)
		return nil, false
	}
	host, err := s.deps.Store.GetHost(r.Context(), hostID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return nil, false
		}
		slog.Error("ui host tab: get host", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return nil, false
	}
	return host, true
}
