package http

import (
	"errors"
	"log/slog"
	stdhttp "net/http"
	"regexp"
	"strconv"
	"strings"

	"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_sources.go — HTML form-driven source-group CRUD. Mounts at:
//   GET  /hosts/{id}/sources                 — list
//   GET  /hosts/{id}/sources/new             — empty form
//   POST /hosts/{id}/sources/new             — create
//   GET  /hosts/{id}/sources/{gid}/edit      — populated form
//   POST /hosts/{id}/sources/{gid}/edit      — update
//   POST /hosts/{id}/sources/{gid}/delete    — delete
//
// Per-group Run-now is handled by run_group.go's HTMX-aware
// /hosts/{id}/source-groups/{gid}/run handler.

// hostSourcesPage backs the list view. Each row carries the group plus
// the cheap aggregates the row UI shows (used-by-N-schedules,
// snapshot count by tag).
type hostSourcesPage struct {
	hostChromeData
	Groups []sourceGroupRow
}

type sourceGroupRow struct {
	Group         store.SourceGroup
	UsedBy        int
	SnapshotCount int
}

// sourceFormData carries form state across re-render-on-error. Keep
// keep-* fields as strings so an empty input round-trips as "" (not
// "0"), preserving the operator's intent.
type sourceFormData struct {
	Name                string
	Includes            string // newline-joined for the textarea
	Excludes            string // newline-joined for the textarea
	KeepLast            string
	KeepHourly          string
	KeepDaily           string
	KeepWeekly          string
	KeepMonthly         string
	KeepYearly          string
	RetryMax            int
	RetryBackoffSeconds int
	ConflictDimension   string
	PreHook             string // plaintext; encrypted on save
	PostHook            string
}

// sourceGroupEditPage backs both the new and edit form views.
type sourceGroupEditPage struct {
	hostChromeData
	IsNew      bool
	GroupID    string // empty when IsNew
	Form       sourceFormData
	SaveAction string
	Error      string
}

// nameRE matches the same shape the wireframe + UI hint advertise:
// lowercase alnum, optional `_-`, no leading punctuation. Mirrors what
// works as a restic --tag.
var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)

func (s *Server) handleUIHostSources(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 sources: list groups", "host_id", host.ID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	// Snapshot counts per tag — single fetch, then bucket by tag.
	snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Warn("ui sources: list snapshots", "host_id", host.ID, "err", err)
	}
	snapByTag := make(map[string]int, len(groups))
	for _, sn := range snaps {
		for _, tag := range sn.Tags {
			snapByTag[tag]++
		}
	}

	rows := make([]sourceGroupRow, 0, len(groups))
	for _, g := range groups {
		usedBy, lerr := s.deps.Store.SchedulesUsingGroup(r.Context(), g.ID)
		if lerr != nil {
			slog.Warn("ui sources: usage lookup", "group_id", g.ID, "err", lerr)
		}
		rows = append(rows, sourceGroupRow{
			Group:         g,
			UsedBy:        len(usedBy),
			SnapshotCount: snapByTag[g.Name],
		})
	}

	chrome := s.loadHostChrome(r, *host, "sources", "sources")
	// loadHostChrome already counted groups; reuse count we just got.
	chrome.SourceGroupCount = len(groups)

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

func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	view := s.baseView(r, u)
	view.Title = "New source group · " + host.Name + " · restic-manager"
	view.Page = sourceGroupEditPage{
		hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"),
		IsNew:          true,
		Form:           sourceFormData{RetryMax: 3, RetryBackoffSeconds: 60},
		SaveAction:     "/hosts/" + host.ID + "/sources/new",
	}
	if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
		slog.Error("ui: render source_group_edit (new)", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	gid := chi.URLParam(r, "gid")
	g, err := s.deps.Store.GetSourceGroup(r.Context(), host.ID, gid)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui sources: get group", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	view := s.baseView(r, u)
	view.Title = g.Name + " · " + host.Name + " · restic-manager"
	form := formFromGroup(*g)
	form.PreHook = s.decryptHookOrFallback(g.PreHook, "", host.ID, "pre")
	form.PostHook = s.decryptHookOrFallback(g.PostHook, "", host.ID, "post")
	view.Page = sourceGroupEditPage{
		hostChromeData: s.loadHostChrome(r, *host, "sources", g.Name),
		IsNew:          false,
		GroupID:        gid,
		Form:           form,
		SaveAction:     "/hosts/" + host.ID + "/sources/" + gid + "/edit",
	}
	if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
		slog.Error("ui: render source_group_edit (edit)", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
	}
}

// handleUISourceGroupSave handles both the create (gid empty) and the
// update (gid set) POST. Validates server-side; on error re-renders
// the form with the operator's typed input intact + a banner. On
// success, redirects back to the list.
func (s *Server) handleUISourceGroupSave(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
	}
	gid := chi.URLParam(r, "gid")
	isNew := gid == ""

	form := parseSourceForm(r.PostForm)

	// --- validation ---
	var errMsg string
	switch {
	case form.Name == "":
		errMsg = "Name is required."
	case !nameRE.MatchString(form.Name):
		errMsg = "Name must be lowercase letters, digits, dashes, or underscores (and start with a letter or digit)."
	}
	keepLast, err := parseKeep(form.KeepLast)
	if errMsg == "" && err != nil {
		errMsg = "Keep last must be a non-negative whole number."
	}
	keepHourly, err := parseKeep(form.KeepHourly)
	if errMsg == "" && err != nil {
		errMsg = "Hourly must be a non-negative whole number."
	}
	keepDaily, err := parseKeep(form.KeepDaily)
	if errMsg == "" && err != nil {
		errMsg = "Daily must be a non-negative whole number."
	}
	keepWeekly, err := parseKeep(form.KeepWeekly)
	if errMsg == "" && err != nil {
		errMsg = "Weekly must be a non-negative whole number."
	}
	keepMonthly, err := parseKeep(form.KeepMonthly)
	if errMsg == "" && err != nil {
		errMsg = "Monthly must be a non-negative whole number."
	}
	keepYearly, err := parseKeep(form.KeepYearly)
	if errMsg == "" && err != nil {
		errMsg = "Yearly must be a non-negative whole number."
	}

	// Name uniqueness (per host). On rename, exclude self.
	if errMsg == "" {
		if existing, gerr := s.deps.Store.GetSourceGroupByName(r.Context(), host.ID, form.Name); gerr == nil && existing != nil && existing.ID != gid {
			errMsg = "A source group named \"" + form.Name + "\" already exists on this host."
		}
	}

	if errMsg != "" {
		s.renderSourceFormError(w, r, u, host, gid, isNew, form, errMsg)
		return
	}

	// Encrypt hook bodies (empty → empty stored, clearing the column).
	preEnc, err := s.EncryptHookForGroup(host.ID, "pre", form.PreHook)
	if err != nil {
		slog.Error("ui sources: encrypt pre_hook", "err", err)
		s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt pre-hook — see the server log.")
		return
	}
	postEnc, err := s.EncryptHookForGroup(host.ID, "post", form.PostHook)
	if err != nil {
		slog.Error("ui sources: encrypt post_hook", "err", err)
		s.renderSourceFormError(w, r, u, host, gid, isNew, form, "Couldn't encrypt post-hook — see the server log.")
		return
	}

	g := store.SourceGroup{
		ID:       gid,
		HostID:   host.ID,
		Name:     form.Name,
		Includes: splitLines(form.Includes),
		Excludes: splitLines(form.Excludes),
		RetentionPolicy: store.RetentionPolicy{
			KeepLast: keepLast, KeepHourly: keepHourly, KeepDaily: keepDaily,
			KeepWeekly: keepWeekly, KeepMonthly: keepMonthly, KeepYearly: keepYearly,
		},
		RetryMax:            form.RetryMax,
		RetryBackoffSeconds: form.RetryBackoffSeconds,
		PreHook:             preEnc,
		PostHook:            postEnc,
	}

	if isNew {
		g.ID = ulid.Make().String()
		if err := s.deps.Store.CreateSourceGroup(r.Context(), &g); err != nil {
			slog.Error("ui sources: create", "err", err)
			s.renderSourceFormError(w, r, u, host, "", true, form, "Couldn't create — see the server log for details.")
			return
		}
	} else {
		if err := s.deps.Store.UpdateSourceGroup(r.Context(), &g); err != nil {
			slog.Error("ui sources: update", "err", err)
			s.renderSourceFormError(w, r, u, host, gid, false, form, "Couldn't save — see the server log for details.")
			return
		}
	}
	s.pushScheduleSetAsync(host.ID)

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

func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	host, ok := s.loadHostForUI(w, r)
	if !ok {
		return
	}
	gid := chi.URLParam(r, "gid")

	using, err := s.deps.Store.SchedulesUsingGroup(r.Context(), gid)
	if err != nil {
		slog.Error("ui sources: usage check", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if len(using) > 0 {
		// Shouldn't happen via the UI (delete button is disabled when
		// in use); guard anyway against form-replay / curl.
		stdhttp.Error(w, "remove this group from its schedules first", stdhttp.StatusConflict)
		return
	}

	// Refuse to delete the host's last source group — every host
	// needs at least one to be backup-able. UI disables the button
	// in this case; this guards against form-replay / curl.
	groups, err := s.deps.Store.ListSourceGroupsByHost(r.Context(), host.ID)
	if err != nil {
		slog.Error("ui sources: count groups", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if len(groups) <= 1 {
		stdhttp.Error(w, "this is the host's only source group — create another one first", stdhttp.StatusConflict)
		return
	}

	if err := s.deps.Store.DeleteSourceGroup(r.Context(), host.ID, gid); err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui sources: delete", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	s.pushScheduleSetAsync(host.ID)
	stdhttp.Redirect(w, r, "/hosts/"+host.ID+"/sources", stdhttp.StatusSeeOther)
}

// renderSourceFormError re-renders the edit form with the user's
// typed input intact + an error banner. Returns 422 to signal "form
// rejected" while still returning HTML (mirrors handleUIAddHostPost).
func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) {
	view := s.baseView(r, u)
	view.Title = "Source group · " + host.Name + " · restic-manager"
	saveAction := "/hosts/" + host.ID + "/sources/new"
	crumb := "new source group"
	if !isNew {
		saveAction = "/hosts/" + host.ID + "/sources/" + gid + "/edit"
		crumb = form.Name
	}
	view.Page = sourceGroupEditPage{
		hostChromeData: s.loadHostChrome(r, *host, "sources", crumb),
		IsNew:          isNew,
		GroupID:        gid,
		Form:           form,
		SaveAction:     saveAction,
		Error:          msg,
	}
	w.WriteHeader(stdhttp.StatusUnprocessableEntity)
	if err := s.deps.UI.Render(w, "source_group_edit", view); err != nil {
		slog.Error("ui: render source_group_edit (error)", "err", err)
	}
}

// --- form parsing helpers ---

func parseSourceForm(v map[string][]string) sourceFormData {
	get := func(k string) string { return strings.TrimSpace(firstVal(v, k)) }
	rmax, _ := strconv.Atoi(get("retry_max"))
	rback, _ := strconv.Atoi(get("retry_backoff_seconds"))
	return sourceFormData{
		Name:                get("name"),
		Includes:            firstVal(v, "includes"), // textarea — preserve internal whitespace
		Excludes:            firstVal(v, "excludes"),
		KeepLast:            get("keep_last"),
		KeepHourly:          get("keep_hourly"),
		KeepDaily:           get("keep_daily"),
		KeepWeekly:          get("keep_weekly"),
		KeepMonthly:         get("keep_monthly"),
		KeepYearly:          get("keep_yearly"),
		RetryMax:            rmax,
		RetryBackoffSeconds: rback,
		PreHook:             firstVal(v, "pre_hook"),
		PostHook:            firstVal(v, "post_hook"),
	}
}

func firstVal(v map[string][]string, k string) string {
	if vs, ok := v[k]; ok && len(vs) > 0 {
		return vs[0]
	}
	return ""
}

// parseKeep maps an empty string → nil pointer (no constraint),
// "0" / "N" → *int. Negative or non-numeric → error.
func parseKeep(s string) (*int, error) {
	s = strings.TrimSpace(s)
	if s == "" {
		return nil, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil || n < 0 {
		return nil, errors.New("invalid")
	}
	return &n, nil
}

func splitLines(s string) []string {
	out := []string{}
	for _, line := range strings.Split(s, "\n") {
		if p := strings.TrimSpace(line); p != "" {
			out = append(out, p)
		}
	}
	return out
}

func formFromGroup(g store.SourceGroup) sourceFormData {
	keep := func(p *int) string {
		if p == nil {
			return ""
		}
		return strconv.Itoa(*p)
	}
	return sourceFormData{
		Name:                g.Name,
		Includes:            strings.Join(g.Includes, "\n"),
		Excludes:            strings.Join(g.Excludes, "\n"),
		KeepLast:            keep(g.RetentionPolicy.KeepLast),
		KeepHourly:          keep(g.RetentionPolicy.KeepHourly),
		KeepDaily:           keep(g.RetentionPolicy.KeepDaily),
		KeepWeekly:          keep(g.RetentionPolicy.KeepWeekly),
		KeepMonthly:         keep(g.RetentionPolicy.KeepMonthly),
		KeepYearly:          keep(g.RetentionPolicy.KeepYearly),
		RetryMax:            g.RetryMax,
		RetryBackoffSeconds: g.RetryBackoffSeconds,
		ConflictDimension:   g.ConflictDimension,
		// PreHook/PostHook are decrypted on render (handler-side, not
		// here) since formFromGroup has no AEAD reference.
	}
}
