package web

import (
	"net/http"
	"net/url"
	"strings"
	"time"

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

// R-48 — the offsite restore wizard.
//
// WHY THIS EXISTS. Until v0.153.0 the „Ellenőrző visszaállítás a távoli tárolóból" list rendered up
// to five inline `<form style="display:inline">` blocks per app row. Two of them —
// „Helyreállítás az élő adatok közé (csak a hiányzó fájlok)" and
// „Teljes visszaállítás (fájlok + adatbázis)" — sat next to each other as sibling buttons, and the
// difference between them is whether the customer's data comes back at all. The missing-only merge
// cannot resurrect deleted content; the reconstitution can. An operator who had read the source
// still pressed the wrong one (audits/DIAG-immich-restore-round2-2026-07-19.md, finding 1): the
// controller log shows /backup/offbox/reconstitute was never hit.
//
// The rule R-48 establishes, worth stating where it is implemented: TWO ADJACENT CONTROLS WHOSE
// DIFFERENCE IS "YOUR DATA COMES BACK" VS "YOUR DATA CANNOT COME BACK" MUST NOT BE DISTINGUISHABLE
// ONLY BY LAYOUT. So the list page now carries ONE entry per app — „Visszaállítás…" — and this
// wizard makes the intent an explicit, separately-described choice.
//
// It is deliberately a THIN surface: no new mutation endpoint, no job registry (that is R-45), no
// JSON state API. Every step posts to the endpoint the old buttons posted to, with the same field
// names, and the server re-renders the next step. The only client-side JS is the card reveal the
// escrow wizard already established plus the EXISTING confirm helper and status poll.

// restoreWizardStep is which phase of the wizard the server decided to render. It is derived, never
// stored and never accepted from the request — a customer cannot navigate to a step whose
// preconditions do not hold.
type restoreWizardStep string

const (
	// wizStepIntent is the choice: verify / bring back missing files / full restore.
	wizStepIntent restoreWizardStep = "intent"
	// wizStepPrepareConfirm is the size-gate reveal — the full-restore preparation ran, reported a
	// size, and the customer confirms before the download starts.
	wizStepPrepareConfirm restoreWizardStep = "prepare-confirm"
	// wizStepExecution is "something is running" — every mutation form is suppressed SERVER-SIDE,
	// because the backup manager's single-flight would refuse them anyway and offering a control
	// that is guaranteed to fail is exactly the class of dishonesty R-48 is about.
	wizStepExecution restoreWizardStep = "execution"
)

// restoreWizardInput is the complete set of facts the step derivation is allowed to see. Keeping it
// a plain struct (rather than reading off the Server) is what makes deriveWizardStep a pure,
// table-testable function — the Scenario-B table in restore_wizard_test.go is this struct's
// truth table.
type restoreWizardInput struct {
	// App is the app this wizard page is for.
	App string
	// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The op status
	// is process-wide, so a restore running for app X must suppress app Y's controls.
	//
	// Source it via restoreOpInFlight (the DISPLAY flag), never Manager.IsRunning() — see the note
	// on that helper. Reading the wrong flag makes this field silently always-false for the
	// verification restore, which is the wizard's most-used path.
	OpRunning bool
	// ScratchReady is true when a completed full-restore scratch exists for App. Both the
	// missing-only merge and the true reconstitution require one.
	ScratchReady bool
	// FullPrepApp is the app the size-gate flash binds to (from ?full_prep=). It binds to ONE app:
	// a prepare for X must not reveal a confirm on Y's page.
	FullPrepApp string
	// HasRecentResult is true when THIS app's restore finished a short while ago — see
	// hasRecentRestoreResult. It moves the phase strip to „Eredmény"; it never changes what the
	// customer may do (a finished restore leaves every intent available again).
	HasRecentResult bool
}

// restoreWizardView is what the template renders. The enabled-flags are part of the derivation (not
// separate template conditionals) so that the whole "what may the customer do right now" decision is
// one pure function with one test table.
type restoreWizardView struct {
	Step restoreWizardStep
	// Phase is which label the phase strip highlights. It is NOT the same as Step: a finished restore
	// is back on the intent step (everything is offered again) while the strip rightly says
	// „Eredmény". Keeping them separate is what stopped the strip from having to lie in one direction
	// or the other.
	Phase restoreWizardPhase
	// VerifyEnabled — intent 1: restore into a separate verification folder (mode=unit). Live data
	// is untouched, so this is the only intent available without a prepared scratch.
	VerifyEnabled bool
	// PrepareEnabled — intent 3, first leg: no scratch yet, so the full restore must first be
	// prepared (mode=full, size-gated).
	PrepareEnabled bool
	// PlaceEnabled — intent 2: missing-only merge (/backup/offbox/place). Needs a prepared scratch.
	PlaceEnabled bool
	// RestoreEnabled — intent 3, second leg: the TRUE restore (/backup/offbox/reconstitute). Needs a
	// prepared scratch.
	RestoreEnabled bool
	// CommitPrepareEnabled — the revealed „Teljes visszaállítás indítása (~méret)" confirm
	// (mode=full&confirm=1). Only on the prepare-confirm step.
	CommitPrepareEnabled bool
}

// restoreWizardPhase is the phase-strip highlight. Four labels, all reachable.
type restoreWizardPhase string

const (
	wizPhasePrepare restoreWizardPhase = "elokeszites"
	wizPhaseConfirm restoreWizardPhase = "megerosites"
	wizPhaseExecute restoreWizardPhase = "vegrehajtas"
	wizPhaseResult  restoreWizardPhase = "eredmeny"
)

// restoreResultWindow bounds how long after a finished restore the strip still says „Eredmény".
// Without a bound the last result would light that phase forever — landing on the page a week later
// would claim you had just finished a restore. Same reasoning as escrowCeremonyGraceWindow; shorter,
// because this answers "what just happened", not "are we still waiting".
const restoreResultWindow = 10 * time.Minute

// hasRecentRestoreResult reports whether THIS app has a just-finished restore to show. Pure (the
// clock is a parameter) so the boundary and the wrong-app case are table-testable.
//
// Bound to the app on purpose: the op status is process-wide, so a finished bookstack restore must
// not light „Eredmény" on immich's wizard and show bookstack's message there.
func hasRecentRestoreResult(st backup.RestoreOpStatus, app string, now time.Time) bool {
	if st.Running || st.Last == nil || st.Last.Stack != app || st.Last.FinishedAt.IsZero() {
		return false
	}
	d := now.Sub(st.Last.FinishedAt)
	return d >= 0 && d < restoreResultWindow
}

// deriveWizardStep is the Scenario-B truth table: step and available intents are a PURE function of
// the state, in strict precedence order.
//
//  1. An op is running (any app)  → execution; nothing is offered.
//  2. The size-gate flash is for THIS app → prepare-confirm; only the commit is offered.
//  3. Otherwise → intent. Verification is always available; the two data-touching intents unlock
//     only with a prepared scratch, and without one the full-restore card offers preparation
//     instead.
//
// Precedence matters: execution outranks the flash, because a stale ?full_prep= in the URL must
// never resurrect a commit button while a restore is mid-flight.
func deriveWizardStep(in restoreWizardInput) restoreWizardView {
	if in.OpRunning {
		return restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}
	}
	if in.FullPrepApp != "" && in.FullPrepApp == in.App {
		return restoreWizardView{Step: wizStepPrepareConfirm, Phase: wizPhaseConfirm, CommitPrepareEnabled: true}
	}
	phase := wizPhasePrepare
	if in.HasRecentResult {
		phase = wizPhaseResult
	}
	return restoreWizardView{
		Step:           wizStepIntent,
		Phase:          phase,
		VerifyEnabled:  true,
		PrepareEnabled: !in.ScratchReady,
		PlaceEnabled:   in.ScratchReady,
		RestoreEnabled: in.ScratchReady,
	}
}

// resolveWizardApp finds the wizard's app in the offsite-toggled set — the same gating the list page
// applies. Pure, so the two refusal rows (unknown app, app present but NOT toggled for offsite) are
// table-testable without a live backup manager.
//
// An app that is not toggled has no offsite snapshot to restore FROM, so its wizard would be a page
// of controls that cannot work. Both refusals return nil and the caller redirects — a customer-visible
// URL that survives a bookmark, an app rename or a toggle being switched off must never 500.
func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
	for _, a := range rows {
		if a.Name == name && a.Enabled {
			cp := a
			return &cp
		}
	}
	return nil
}

// restoreOpInFlight reports whether a restore op is in flight, FOR DISPLAY.
//
// **Use this, not `Manager.IsRunning()`.** The Manager carries two different booleans and they are
// not interchangeable:
//
//   - `m.running` (read by `IsRunning`) is the CONCURRENCY single-flight. It is acquired *inside*
//     the restore function, on the background goroutine — and `RestoreOffboxScratch` never acquires
//     it at all. So for the verification restore and the full-restore preparation — the wizard's two
//     most-used actions, and the long ones, since they stream from restic — `IsRunning()` is false
//     for the entire operation.
//   - `m.opRunning` (read by `RestoreStatus`) is the DISPLAY flag, set synchronously by
//     `BeginRestoreOp` in the handler *before* the goroutine launches and cleared by `EndRestoreOp`.
//     It covers all four offsite actions with no start-up window.
//
// v0.154.0 shipped with `IsRunning()` here, which made the execution step unreachable for
// `RestoreOffboxScratch`: the page offered all three intents, with live buttons, while a restore was
// downloading — and the progress banner (which polls the op status) contradicted it on the same
// screen. Caught by the operator on the first live click-through.
func restoreOpInFlight(st backup.RestoreOpStatus) bool {
	return st.Running
}

// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
// list page now offers per app.
//
// Unknown app, an app that is not toggled for offsite backup, or an unconfigured offsite target all
// redirect back to the list with a Hungarian flash. They must never 500: the URL is customer-visible
// and survives a bookmark, an app rename and a toggle being switched off.
func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Request) {
	if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
		offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
		return
	}
	app := strings.TrimSpace(r.URL.Query().Get("name"))
	if app == "" {
		offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
		return
	}

	row := resolveWizardApp(s.buildOffboxApps(), app)
	if row == nil {
		offboxRedirectTo(w, r, "/backups/restore", "Ez az alkalmazás nincs távoli mentésre kijelölve.", true)
		return
	}

	data := s.backupsCommonData("backups-restore", "Visszaállítás — "+row.DisplayName, r)

	st := s.backupMgr.RestoreStatus()
	in := restoreWizardInput{
		App:             app,
		OpRunning:       restoreOpInFlight(st),
		ScratchReady:    s.backupMgr.OffboxFullScratchReady(app),
		FullPrepApp:     strings.TrimSpace(r.URL.Query().Get("full_prep")),
		HasRecentResult: hasRecentRestoreResult(st, app, time.Now()),
	}
	view := deriveWizardStep(in)

	data["App"] = app
	data["AppDisplayName"] = row.DisplayName
	data["AppSlug"] = row.Slug
	data["Wizard"] = view
	data["FullPrepSize"] = strings.TrimSpace(r.URL.Query().Get("full_size"))
	// The pair-honesty panel (R-43): how old the database half is, whether the two halves come from
	// the same run, and whether the dump looks customer-empty. Only meaningful once a scratch exists.
	if in.ScratchReady {
		pair := s.backupMgr.OffsiteScratchPair(app)
		data["Pair"] = pair
	} else {
		data["Pair"] = backup.OffsitePairInfo{}
	}
	// The running op's identity, so the execution card can say WHAT is running rather than a bare
	// "please wait" — including the case where it belongs to a different app.
	data["RunningStack"] = st.Stack
	// The outcome card for the „Eredmény" phase — the same message the redirect flash carried, but it
	// survives a reload, which the flash does not.
	if in.HasRecentResult {
		data["LastResult"] = st.Last
	}

	s.executeTemplate(w, r, "backups_restore_wizard", data)
}

// restoreWizardPath builds the wizard URL for an app. Handlers redirect here after an app-scoped
// mutation so the customer lands back on the surface they acted from, not on the list.
func restoreWizardPath(app string) string {
	return "/backups/restore/app?name=" + url.QueryEscape(app)
}
