package web

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
	"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)

// Customer RESET (v0.61.0) — the middle lifecycle tier (host delete < RESET < customer DELETE). One
// operator action returns a customer to pre-first-install: every OPERATIONAL trace dies (offsite repo
// + PBS namespace + credentials + DR recipe + claim state + retained escrow custody), while IDENTITY
// and the BASIC CONFIG survive (customer_configs row, provenance rows, the audit event stream).
//
// Orchestration discipline (spec §3): external teardown runs FIRST, the DB purge runs LAST
// (publish-last). Every leg is idempotent, so a partial run is simply re-run from the top — a failed
// external leg is a clean journal entry, and the DB purge (which erases the descriptors that say what
// still needs tearing down) is withheld until every external leg is ok. Provenance/events are NEVER
// wiped — the audit trail outlives every lifecycle tier.

// offsiteChoice reads the customer's offsite tier selection out of config_json (the same shape the
// re-issue/freeze handlers read). enabled=false means no offsite leg to run.
func offsiteChoice(configJSON string) (enabled bool, typ string) {
	var o struct {
		Offsite struct {
			Enabled bool   `json:"enabled"`
			Type    string `json:"type"`
		} `json:"offsite"`
	}
	_ = json.Unmarshal([]byte(configJSON), &o)
	return o.Offsite.Enabled, o.Offsite.Type
}

// handleCustomerResetPreview — GET /configs/{id}/reset. Returns the live inventory the confirm surface
// renders (ruling 4): what a RESET would destroy right now. Read-only — no writes, no external calls
// beyond the label lookups needed to name the offsite resource. `refused` is true when a host row still
// exists (ruling 3: RESET refuses until the operator deletes the hosts first).
func (s *Server) handleCustomerResetPreview(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil {
		s.logger.Printf("[ERROR] reset preview %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.NotFound(w, r)
		return
	}
	inv, err := s.store.CustomerResetInventory(customerID)
	if err != nil {
		s.logger.Printf("[ERROR] reset preview %s: inventory: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	offsiteEnabled, offsiteType := offsiteChoice(cfg.ConfigJSON)
	offsiteName := ""
	if offsiteEnabled && s.offsite != nil {
		ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
		defer cancel()
		if n, oerr := s.offsite.OffsiteIdentifier(ctx, customerID, offsiteType); oerr != nil {
			s.logger.Printf("[WARN] reset preview %s: offsite identifier lookup: %v", customerID, oerr)
		} else {
			offsiteName = n
		}
	}
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(map[string]any{
		"customer_id":            customerID,
		"host_count":             inv.HostCount,
		"refused":                inv.HostCount > 0, // ruling 3
		"superseded_blobs":       inv.SupersededBlobs,
		"escrow_ack_required":    inv.SupersededBlobs > 0, // ruling 1: separate custody-destruction ack
		"dr_recipe_present":      inv.DRRecipePresent,
		"one_time_secret":        inv.OneTimeSecretPresent,
		"claim_present":          inv.ClaimPresent,
		"offsite_enabled":        offsiteEnabled,
		"offsite_type":           offsiteType,
		"offsite_identifier":     offsiteName,
		"pbs_tenancy_configured": s.tenantsync != nil,
	})
}

// handleCustomerReset — POST /configs/{id}/reset. Executes the reset. Preconditions (Scenario A + the
// confirm gates) are checked BEFORE any write or external call: a refused reset leaves zero side effects.
func (s *Server) handleCustomerReset(w http.ResponseWriter, r *http.Request, customerID string) {
	cfg, err := s.store.GetCustomerConfig(customerID)
	if err != nil {
		s.logger.Printf("[ERROR] reset %s: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	if cfg == nil {
		http.NotFound(w, r)
		return
	}
	inv, err := s.store.CustomerResetInventory(customerID)
	if err != nil {
		s.logger.Printf("[ERROR] reset %s: inventory: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}

	// Ruling 3 (Scenario A): RESET REFUSES while any host row exists. RESET never deletes hosts — the
	// operator deletes them first. 409 with ZERO writes and ZERO external calls.
	if inv.HostCount > 0 {
		s.logger.Printf("[WARN] reset %s REFUSED: %d host row(s) still present — delete the hosts first", customerID, inv.HostCount)
		http.Error(w, "Reset refused: this customer still has host(s). Delete every host first — reset never deletes hosts.", http.StatusConflict)
		return
	}

	// Typed-confirmation gate: the operator must type the exact customer-id.
	if r.FormValue("confirm_id") != customerID {
		http.Error(w, "Reset refused: the typed customer-id does not match.", http.StatusBadRequest)
		return
	}
	// Ruling 1: destroying retained escrow custody (M>0) needs its OWN separate acknowledgment.
	escrowAck := r.FormValue("escrow_ack") == "1"
	if inv.SupersededBlobs > 0 && !escrowAck {
		http.Error(w, "Reset refused: destroying the retained recovery-key custody requires the separate acknowledgment.", http.StatusBadRequest)
		return
	}

	// From here the reset is committed. Detached ctx (spec: once teardown starts it must run to a clean
	// journal state regardless of the operator's browser). External legs FIRST.
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Minute)
	defer cancel()

	resetID, err := s.store.StartCustomerReset(customerID, escrowAck)
	if err != nil {
		s.logger.Printf("[ERROR] reset %s: open journal: %v", customerID, err)
		http.Error(w, "Internal error", http.StatusInternalServerError)
		return
	}
	s.logger.Printf("[INFO] customer RESET started for %s (journal #%d, escrow_ack=%t)", customerID, resetID, escrowAck)

	// Standalone RESET purges the retained custody itself, gated by the ack it just checked.
	if lerr := s.commitCustomerReset(ctx, cfg, customerID, resetID, escrowAck); lerr != nil {
		http.Error(w, lerr.Msg, lerr.Status)
		return
	}
	if ferr := s.store.FinishCustomerReset(resetID); ferr != nil {
		s.logger.Printf("[WARN] reset %s: journal finish stamp failed (state is complete): %v", customerID, ferr)
	}

	// Audit event (SURVIVES — the reset is part of the customer's permanent history).
	msg := "Ügyfél-visszaállítás (RESET): minden működési állapot törölve (offsite tároló, PBS névtér, DR-recept, azonosítási állapot). Az azonosság és az alapkonfiguráció megmaradt."
	if escrowAck {
		msg += " A megőrzött helyreállítási-kulcs letét is megsemmisült (megerősítve)."
	}
	if _, eerr := s.store.SaveEvent(customerID, "customer_reset", "warning", msg, "", "hub"); eerr != nil {
		s.logger.Printf("[WARN] reset %s: save audit event: %v", customerID, eerr)
	}

	s.logger.Printf("[INFO] customer RESET complete for %s (journal #%d) — identity + basic config retained", customerID, resetID)
	// v0.67.0 (R-36 sub-item): a RESET customer is about to be re-onboarded, and RESET cleared the
	// claim state, so the very next thing that happens is a box asking to be bound. Mint the link now
	// so the console banner's promised email is already true. Placed AFTER the DB purge on purpose —
	// PurgeCustomerResetDBState would otherwise sweep the token we just minted. Never fails the reset.
	s.autoMintSelfBindLink(customerID, cfg.Email, "RESET completion")
	s.bumpIntent(customerID) // wake any holding wait so a lingering box sees the cleared state promptly
	http.Redirect(w, r, "/customers/"+customerID+"?flash=reset_done", http.StatusSeeOther)
}

// resetLegError names the leg of the committed RESET sequence that failed, carrying the exact
// operator-facing message + HTTP status the standalone RESET handler has always returned. The DELETE
// cascade (v0.69.0, R-25b) reuses the same values so a mid-cascade failure names its leg too.
type resetLegError struct {
	Leg    string // journal leg name: hetzner | pbs | claim | descriptor | db_purge
	Status int
	Msg    string
	Err    error
}

func (e *resetLegError) Error() string {
	if e.Err != nil {
		return e.Leg + ": " + e.Err.Error()
	}
	return e.Leg
}

// commitCustomerReset runs the COMMITTED reset sequence against an already-gated customer: external
// teardown FIRST (Hetzner, PBS), then the DB side (claim → descriptor → purge), each leg stamped into
// the journal so a failed run is resumable. It deliberately owns no gate, no audit event, no journal
// open/close and no redirect — those belong to the caller, because the two callers differ there:
//
//   - standalone RESET (v0.61.0): purgeEscrow = the operator's escrow_ack; the customer survives.
//   - DELETE cascade (v0.69.0, R-25b): purgeEscrow = FALSE — retained custody is purged exactly ONCE,
//     in the cascade's final leg (DeleteCustomerConfig, the one true purge point). Purging here too
//     would split the single custody-destruction point across two legs.
//
// Behaviour for the standalone caller is byte-identical to v0.68.1 (same order, same leg names, same
// messages, same status codes).
//
// GHOST CUSTOMERS (v0.70.0): cfg may be nil — the DELETE cascade also runs against a customer whose
// config row is already gone but whose residue is not (a pre-v0.70.0 delete leaves the report stream
// behind; see store/customer_delete.go). The standalone RESET handler 404s on a nil config before it
// ever gets here, so this path is cascade-only. With no config row the offsite DESCRIPTOR is
// unknowable, so the Hetzner leg is skipped and SAYS SO in the journal (`skipped_no_config`) rather
// than silently reporting "skipped"; PBS is customer-id-keyed and idempotent, so it still runs.
func (s *Server) commitCustomerReset(ctx context.Context, cfg *store.CustomerConfig, customerID string, resetID int64, purgeEscrow bool) *resetLegError {
	if cfg != nil {
		customerID = cfg.CustomerID
	}

	// Leg: Hetzner offsite (repo DATA destroyed). Only when the customer chose an offsite tier.
	offsiteEnabled, offsiteType := false, ""
	if cfg != nil {
		offsiteEnabled, offsiteType = offsiteChoice(cfg.ConfigJSON)
	}
	if cfg == nil {
		// No descriptor to read — never guess a tier, and never let the journal imply "nothing to do".
		s.logger.Printf("[WARN] %s: no config row — the offsite (Hetzner) teardown CANNOT be determined and is SKIPPED; verify the Hetzner side by hand", customerID)
		_ = s.store.UpdateResetLeg(resetID, "hetzner", "skipped_no_config")
	} else if offsiteEnabled && s.offsite != nil {
		if derr := s.offsite.Deprovision(ctx, customerID, offsiteType); derr != nil {
			_ = s.store.UpdateResetLeg(resetID, "hetzner", "failed")
			s.logger.Printf("[ERROR] reset %s: hetzner deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
			return &resetLegError{Leg: "hetzner", Status: http.StatusBadGateway, Err: derr,
				Msg: "Reset incomplete: the offsite (Hetzner) teardown failed — nothing was purged; re-run to resume. (" + derr.Error() + ")"}
		}
		_ = s.store.UpdateResetLeg(resetID, "hetzner", "ok")
		s.logger.Printf("[INFO] reset %s: offsite deprovisioned (repo data destroyed)", customerID)
	} else {
		_ = s.store.UpdateResetLeg(resetID, "hetzner", "skipped")
	}

	// Leg: PBS DR tenancy (namespace + backups + token destroyed). The namespace is customer-id-keyed
	// and survives host deletion, so it is torn down here by id; idempotent when absent.
	if s.tenantsync != nil {
		if _, derr := s.tenantsync.Deprovision(ctx, customerID); derr != nil {
			_ = s.store.UpdateResetLeg(resetID, "pbs", "failed")
			s.logger.Printf("[ERROR] reset %s: PBS deprovision FAILED (journal #%d retained; re-run to resume): %v", customerID, resetID, derr)
			return &resetLegError{Leg: "pbs", Status: http.StatusBadGateway, Err: derr,
				Msg: "Reset incomplete: the PBS namespace teardown failed — nothing was purged; re-run to resume. (" + derr.Error() + ")"}
		}
		_ = s.store.UpdateResetLeg(resetID, "pbs", "ok")
		s.logger.Printf("[INFO] reset %s: PBS tenancy deprovisioned", customerID)
	} else {
		_ = s.store.UpdateResetLeg(resetID, "pbs", "skipped")
	}

	// All external legs are ok — now the DB side (publish-last, one leg at a time so the journal
	// records where a mid-purge crash stopped). Claim → unclaimed (fresh code next onboarding).
	if s.claimEngine != nil && cfg != nil {
		if cerr := s.claimEngine.ResetToUnclaimed(cfg); cerr != nil {
			_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
			s.logger.Printf("[ERROR] reset %s: claim reset failed: %v", customerID, cerr)
			return &resetLegError{Leg: "claim", Status: http.StatusInternalServerError, Err: cerr,
				Msg: "Reset incomplete: the claim reset failed — re-run to resume. (" + cerr.Error() + ")"}
		}
	} else if derr := s.store.DeleteClaim(customerID); derr != nil { // no engine wired: use the store primitive directly
		_ = s.store.UpdateResetLeg(resetID, "claim", "failed")
		s.logger.Printf("[ERROR] reset %s: claim delete failed: %v", customerID, derr)
		return &resetLegError{Leg: "claim", Status: http.StatusInternalServerError, Err: derr, Msg: "Internal error"}
	}
	_ = s.store.UpdateResetLeg(resetID, "claim", "ok")

	// Clear the provisioned offsite descriptor (keep the tier CHOICE, drop provisioned host/user/repo/
	// fingerprint) and re-save → ConfigVersion bump. Identity + basic config survive intact.
	// Ghost customers have no row to clear OR to re-save — re-saving here would RESURRECT the very
	// record the cascade is deleting, so the leg is skipped, not "made to work".
	if cfg == nil {
		_ = s.store.UpdateResetLeg(resetID, "descriptor", "skipped_no_config")
	} else {
		newConfigJSON, cerr := offsite.ClearProvisionedDescriptor(cfg.ConfigJSON)
		if cerr != nil {
			_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
			s.logger.Printf("[ERROR] reset %s: clear offsite descriptor: %v", customerID, cerr)
			return &resetLegError{Leg: "descriptor", Status: http.StatusInternalServerError, Err: cerr, Msg: "Internal error"}
		}
		cfg.ConfigJSON = newConfigJSON
		if serr := s.store.SaveCustomerConfig(cfg); serr != nil {
			_ = s.store.UpdateResetLeg(resetID, "descriptor", "failed")
			s.logger.Printf("[ERROR] reset %s: save cleared config: %v", customerID, serr)
			return &resetLegError{Leg: "descriptor", Status: http.StatusInternalServerError, Err: serr, Msg: "Internal error"}
		}
		_ = s.store.UpdateResetLeg(resetID, "descriptor", "ok")
	}

	// DB purge LAST: retained escrow (ack-gated), one-time secret, DR recipe, log bundles.
	if perr := s.store.PurgeCustomerResetDBState(customerID, purgeEscrow); perr != nil {
		_ = s.store.UpdateResetLeg(resetID, "db_purge", "failed")
		s.logger.Printf("[ERROR] reset %s: DB purge failed: %v", customerID, perr)
		return &resetLegError{Leg: "db_purge", Status: http.StatusInternalServerError, Err: perr,
			Msg: "Reset incomplete: the DB purge failed — re-run to resume. (" + perr.Error() + ")"}
	}
	_ = s.store.UpdateResetLeg(resetID, "db_purge", "ok")
	return nil
}
