package http

import (
	"context"
	"errors"
	"log/slog"
	stdhttp "net/http"
	"sort"
	"strings"
	"time"

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

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

// ui_restore.go — restore wizard backend (P3-01).
//
//   GET  /hosts/{id}/restore                              wizard step 1 (snapshot picker)
//   GET  /hosts/{id}/snapshots/{sid}/restore              wizard with snapshot pre-selected
//   GET  /hosts/{id}/restore/tree                         HTMX partial: one tree node + children
//   POST /hosts/{id}/restore                              dispatch the restore job

// hostRestorePage is the model for the wizard template.
type hostRestorePage struct {
	hostChromeData

	// Snapshot picker rows; rendered by the template into the step-1
	// table. Limited to most-recent N (the operator can refine on
	// snapshot ID if they need an older one — out of scope for v1).
	Snapshots []store.Snapshot

	// Selected is non-nil iff a snapshot has been chosen — either via
	// the deep-link path /hosts/{id}/snapshots/{sid}/restore or by a
	// previous form submission that the wizard re-rendered.
	Selected *store.Snapshot

	// Default target dir — surfaced in the step-3 radio card.
	DefaultTargetDir string

	// Online mirrors Hub.Connected so the dispatch button can be
	// disabled at render time when the agent is offline.
	Online bool

	// Error is shown as a banner above the wizard. Re-render-friendly:
	// the operator's snapshot/path/target choices survive the round-trip.
	Error string

	// Form fields preserved on validation re-render. The template
	// reads these to pre-tick checkboxes etc; the names match the
	// POST form keys.
	FormPaths     []string // "/etc/nginx/sites-available/alfa.conf"
	FormInPlace   bool
	FormTargetDir string
	FormConfirmHN string // typed-confirm input value
}

// handleUIRestoreGet renders the wizard. URL variants:
//   - /hosts/{id}/restore                       — step 1 = pick snapshot
//   - /hosts/{id}/snapshots/{sid}/restore       — snapshot pre-selected
func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	host, err := s.deps.Store.GetHost(r.Context(), hostID)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			stdhttp.NotFound(w, r)
			return
		}
		slog.Error("ui restore: get host", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	page := hostRestorePage{
		hostChromeData:   s.loadHostChrome(r, *host, "snapshots", "restore"),
		DefaultTargetDir: defaultRestoreTargetDir(),
		Online:           s.deps.Hub.Connected(host.ID),
	}
	snaps, err := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
	if err != nil {
		slog.Error("ui restore: list snapshots", "host_id", hostID, "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if len(snaps) > 100 {
		snaps = snaps[:100]
	}
	page.Snapshots = snaps

	// Snapshot deep-link variant — if the URL carries a sid, prefill it.
	if sid := chi.URLParam(r, "sid"); sid != "" {
		for i := range snaps {
			if snaps[i].ID == sid || snaps[i].ShortID == sid {
				p := snaps[i]
				page.Selected = &p
				break
			}
		}
	}

	view := s.baseView(r, u)
	view.Title = "Restore · " + host.Name
	view.Page = page
	if err := s.deps.UI.Render(w, "host_restore", view); err != nil {
		slog.Error("ui restore: render", "err", err)
	}
}

// handleUIRestorePost validates the form and dispatches the restore
// job. On validation error re-renders the wizard with the error
// banner + the operator's input intact.
func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	host, err := s.deps.Store.GetHost(r.Context(), hostID)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	if err := r.ParseForm(); err != nil {
		stdhttp.Error(w, "bad form", stdhttp.StatusBadRequest)
		return
	}

	snapshotID := strings.TrimSpace(r.PostForm.Get("snapshot_id"))
	paths := r.PostForm["paths"] // multiple checkbox values
	inPlace := r.PostForm.Get("target_mode") == "in_place"
	targetDir := strings.TrimSpace(r.PostForm.Get("target_dir"))
	confirmHN := strings.TrimSpace(r.PostForm.Get("confirm_hostname"))

	rerender := func(errMsg string, status int) {
		page := hostRestorePage{
			hostChromeData:   s.loadHostChrome(r, *host, "snapshots", "restore"),
			DefaultTargetDir: defaultRestoreTargetDir(),
			Online:           s.deps.Hub.Connected(host.ID),
			Error:            errMsg,
			FormPaths:        paths,
			FormInPlace:      inPlace,
			FormTargetDir:    targetDir,
			FormConfirmHN:    confirmHN,
		}
		snaps, _ := s.deps.Store.ListSnapshotsByHost(r.Context(), hostID)
		if len(snaps) > 100 {
			snaps = snaps[:100]
		}
		page.Snapshots = snaps
		for i := range snaps {
			if snaps[i].ID == snapshotID || snaps[i].ShortID == snapshotID {
				ss := snaps[i]
				page.Selected = &ss
				break
			}
		}
		view := s.baseView(r, u)
		view.Title = "Restore · " + host.Name
		view.Page = page
		w.WriteHeader(status)
		_ = s.deps.UI.Render(w, "host_restore", view)
	}

	if snapshotID == "" {
		rerender("Pick a snapshot first.", stdhttp.StatusUnprocessableEntity)
		return
	}
	cleanPaths := make([]string, 0, len(paths))
	for _, p := range paths {
		p = strings.TrimSpace(p)
		if p == "" {
			continue
		}
		if !strings.HasPrefix(p, "/") {
			rerender("Paths must be absolute (start with /).", stdhttp.StatusUnprocessableEntity)
			return
		}
		cleanPaths = append(cleanPaths, p)
	}
	if len(cleanPaths) == 0 {
		rerender("Pick at least one file or directory to restore.", stdhttp.StatusUnprocessableEntity)
		return
	}

	if inPlace {
		if confirmHN != host.Name {
			rerender("Type the host name exactly to confirm an in-place (overwrite) restore.",
				stdhttp.StatusUnprocessableEntity)
			return
		}
	} else {
		// New-directory mode: trust the operator's chosen target.
		// Empty falls back to the default. Validate it's either
		// absolute or starts with $HOME / ~/ (the agent expands
		// these at run time).
		if targetDir == "" {
			targetDir = defaultRestoreTargetDir()
		}
		if !looksLikeRestoreTarget(targetDir) {
			rerender("Target must be an absolute path, or start with $HOME or ~/.",
				stdhttp.StatusUnprocessableEntity)
			return
		}
	}

	if !s.deps.Hub.Connected(host.ID) {
		rerender("Agent is offline. Try again when it reconnects.",
			stdhttp.StatusServiceUnavailable)
		return
	}

	// Build a new job id up-front so we can substitute it into the
	// new-directory target path. The agent will additionally expand
	// $HOME / ~/ before invoking restic.
	jobID := ulid.Make().String()
	finalTarget := ""
	if !inPlace {
		finalTarget = strings.ReplaceAll(targetDir, "<job-id>", jobID)
	}

	now := time.Now().UTC()
	if err := s.deps.Store.CreateJob(r.Context(), store.Job{
		ID:        jobID,
		HostID:    host.ID,
		Kind:      string(api.JobRestore),
		ActorKind: "user",
		ActorID:   &u.ID,
		CreatedAt: now,
	}); err != nil {
		slog.Error("ui restore: create job", "err", err)
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}

	payload := api.CommandRunPayload{
		JobID: jobID,
		Kind:  api.JobRestore,
		Restore: &api.RestorePayload{
			SnapshotID: snapshotID,
			Paths:      cleanPaths,
			InPlace:    inPlace,
			TargetDir:  finalTarget,
		},
	}
	env, err := api.Marshal(api.MsgCommandRun, jobID, payload)
	if err != nil {
		stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
		return
	}
	if err := s.deps.Hub.Send(r.Context(), host.ID, env); err != nil {
		slog.Warn("ui restore: dispatch failed", "err", err)
		rerender("Couldn't deliver the restore command (agent went offline).",
			stdhttp.StatusServiceUnavailable)
		return
	}
	_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
		ID:         ulid.Make().String(),
		UserID:     &u.ID,
		Actor:      "user",
		Action:     "host.restore",
		TargetKind: ptr("host"),
		TargetID:   &host.ID,
		TS:         now,
	})

	// HTMX redirect (or vanilla redirect) to the live job log.
	jobURL := "/jobs/" + jobID
	if r.Header.Get("HX-Request") == "true" {
		w.Header().Set("HX-Redirect", jobURL)
		w.WriteHeader(stdhttp.StatusNoContent)
		return
	}
	stdhttp.Redirect(w, r, jobURL, stdhttp.StatusSeeOther)
}

// hostRestoreTreePage is the data shape for the tree-node HTMX partial.
type hostRestoreTreePage struct {
	HostID     string
	SnapshotID string
	Path       string
	Children   []treeChildView
	Error      string
}

// treeChildView is one row of the tree (a direct child of Path).
type treeChildView struct {
	Name  string
	Type  string // dir | file | symlink
	Path  string // full path, used in the checkbox value
	Size  int64
	IsDir bool
}

// handleUIRestoreTree is the HTMX-served partial that loads one
// directory's children. Called when the operator clicks an expand
// chevron in the wizard's tree browser. Caches via fetchTreeWithCache.
func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Request) {
	u := s.requireUIUser(w, r)
	if u == nil {
		return
	}
	hostID := chi.URLParam(r, "id")
	host, err := s.deps.Store.GetHost(r.Context(), hostID)
	if err != nil {
		stdhttp.NotFound(w, r)
		return
	}
	q := r.URL.Query()
	snapshotID := strings.TrimSpace(q.Get("snapshot"))
	pathArg := strings.TrimSpace(q.Get("path"))
	if pathArg == "" {
		pathArg = "/"
	}
	if snapshotID == "" {
		stdhttp.Error(w, "snapshot required", stdhttp.StatusBadRequest)
		return
	}
	if !s.deps.Hub.Connected(host.ID) {
		// Render the partial with an error message rather than 503ing
		// — the wizard renders the error inline next to the failed node.
		page := hostRestoreTreePage{
			HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
			Error: "agent offline",
		}
		view := s.baseView(r, u)
		view.Page = page
		_ = s.deps.UI.RenderPartial(w, "tree_node", view)
		return
	}

	sessionID := sessionIDFromCookie(r)
	ctx, cancel := context.WithTimeout(r.Context(), 35*time.Second)
	defer cancel()

	result, err := s.fetchTreeWithCache(ctx, sessionID, host.ID, snapshotID, pathArg)
	if err != nil {
		page := hostRestoreTreePage{
			HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
			Error: err.Error(),
		}
		view := s.baseView(r, u)
		view.Page = page
		_ = s.deps.UI.RenderPartial(w, "tree_node", view)
		return
	}
	if result.Error != "" {
		page := hostRestoreTreePage{
			HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
			Error: result.Error,
		}
		view := s.baseView(r, u)
		view.Page = page
		_ = s.deps.UI.RenderPartial(w, "tree_node", view)
		return
	}

	children := make([]treeChildView, 0, len(result.Entries))
	for _, e := range result.Entries {
		full := joinTreePath(pathArg, e.Name)
		children = append(children, treeChildView{
			Name: e.Name, Type: e.Type, Path: full,
			Size:  e.Size,
			IsDir: e.Type == "dir",
		})
	}
	// Stable order: dirs first, then files, alphabetically.
	sort.SliceStable(children, func(i, j int) bool {
		if children[i].IsDir != children[j].IsDir {
			return children[i].IsDir
		}
		return children[i].Name < children[j].Name
	})

	page := hostRestoreTreePage{
		HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
		Children: children,
	}
	view := s.baseView(r, u)
	view.Page = page
	if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil {
		slog.Warn("ui restore tree: render partial", "err", err)
	}
}

// defaultRestoreTargetDir is the placeholder shown on the step-3
// New-directory radio card and the value used when the operator
// leaves the field blank. The agent runs as root under systemd, so
// we surface /root explicitly rather than $HOME — operators were
// confused by "agent user's home" copy when the underlying user is
// always root anyway. <job-id> is substituted at dispatch. The unit
// no longer pins ReadWritePaths (ProtectSystem=full + no ProtectHome),
// so operators can point this at /home/<user>/<wherever> directly
// when they want a specific destination.
func defaultRestoreTargetDir() string {
	return "/root/rm-restore/<job-id>/"
}

// looksLikeRestoreTarget validates the operator-supplied target dir
// is a shape the agent can sensibly resolve. We accept absolute
// paths and a couple of agent-side expansions ($HOME, ~/). Other env
// vars are deliberately rejected — operator-supplied paths shouldn't
// be able to pick up arbitrary agent env values.
func looksLikeRestoreTarget(p string) bool {
	if p == "" {
		return false
	}
	switch {
	case strings.HasPrefix(p, "/"):
		return true
	case strings.HasPrefix(p, "$HOME/"), p == "$HOME":
		return true
	case strings.HasPrefix(p, "${HOME}/"), p == "${HOME}":
		return true
	case strings.HasPrefix(p, "~/"), p == "~":
		return true
	}
	return false
}

// sessionIDFromCookie returns the operator's session cookie value,
// used as the cache key scope for the tree-list cache. Unauthenticated
// requests don't reach this point, so an empty cookie value would
// only happen if requireUIUser is bypassed in tests — fall back to
// the request remote addr for those cases.
func sessionIDFromCookie(r *stdhttp.Request) string {
	if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
		return c.Value
	}
	return r.RemoteAddr
}

// joinTreePath combines a directory path and a child name into an
// absolute snapshot-relative path, normalising any duplicate slashes.
func joinTreePath(dir, name string) string {
	if dir == "" || dir == "/" {
		return "/" + name
	}
	return strings.TrimRight(dir, "/") + "/" + name
}

// satisfy unused-import if compile order shifts.
var _ = ui.User{}
