package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

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

// „Megosztás" — LAN network sharing (R-7 slice 1). The customer enables sharing, sets one household
// SMB password, and exports folders. Every customer-supplied path goes through sharingResolvePath,
// which is load-bearing security, not defence in depth.

// errNotShareable is the single refusal surfaced for every deny-listed / out-of-root path. It is
// deliberately uniform: a distinct message per reason would let the picker act as a filesystem oracle.
var errNotShareable = errors.New("Ez a mappa nem osztható meg.")

// pathWithin reports whether path IS root or lives under it. Segment-wise and separator-agnostic, so
// neither a "/mnt/drive-evil" sibling nor a non-POSIX separator can pass as containment.
func pathWithin(path, root string) bool {
	p := filepath.ToSlash(filepath.Clean(path))
	r := filepath.ToSlash(filepath.Clean(root))
	return p == r || strings.HasPrefix(p, r+"/")
}

// sharingOwningRoot returns the registered, non-decommissioned storage root that contains path.
func (s *Server) sharingOwningRoot(path string) (string, bool) {
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.Decommissioned {
			continue
		}
		if pathWithin(path, sp.Path) {
			return sp.Path, true
		}
	}
	return "", false
}

// sharingResolvePath is THE security gate for this feature. A path is accepted only when it
// (1) is absolute, (2) resolves — SYMLINKS INCLUDED — into a registered live storage root, (3) is not
// inside a deny-listed system subtree (appdata/, backups/, the drive root itself, felhom-data), and
// (4) is a real directory. EvalSymlinks runs BEFORE the containment assert so a symlink planted inside
// a root cannot point outside it.
func (s *Server) sharingResolvePath(raw string) (string, error) {
	if strings.TrimSpace(raw) == "" {
		return "", errNotShareable
	}
	clean := filepath.Clean(raw)
	if !filepath.IsAbs(clean) {
		return "", errNotShareable
	}
	resolved, err := filepath.EvalSymlinks(clean)
	if err != nil {
		return "", errNotShareable
	}
	root, ok := s.sharingOwningRoot(resolved)
	if !ok {
		return "", errNotShareable
	}
	// A whole drive is never shareable (exact match — the subtree rule below must NOT include the
	// root, or every legitimate share under it would be refused).
	if filepath.Clean(resolved) == filepath.Clean(root) {
		return "", errNotShareable
	}
	for _, denied := range stacks.SharingDeniedRoots(root) {
		if pathWithin(resolved, denied) {
			return "", errNotShareable
		}
	}
	fi, err := os.Stat(resolved)
	if err != nil || !fi.IsDir() {
		return "", errNotShareable
	}
	return resolved, nil
}

// sharingResolveStorageRoot validates a storage ROOT chosen for the "new folder" flow.
//
// This is deliberately NOT sharingResolvePath: that one validates a SHARE TARGET and therefore
// refuses the drive root itself (a whole drive is never shareable). Here the root is not the share —
// the new folder is created UNDER it — so the accept condition is "is EXACTLY a registered, live
// storage root", which is strictly tighter than the share-target guard.
func (s *Server) sharingResolveStorageRoot(raw string) (string, error) {
	if strings.TrimSpace(raw) == "" {
		return "", errNotShareable
	}
	clean := filepath.Clean(raw)
	if !filepath.IsAbs(clean) {
		return "", errNotShareable
	}
	resolved, err := filepath.EvalSymlinks(clean)
	if err != nil {
		return "", errNotShareable
	}
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.Decommissioned || sp.Disconnected {
			continue
		}
		spResolved, serr := filepath.EvalSymlinks(sp.Path)
		if serr != nil {
			spResolved = filepath.Clean(sp.Path)
		}
		if filepath.Clean(resolved) == filepath.Clean(spResolved) {
			return resolved, nil
		}
	}
	return "", errNotShareable
}

// ShareRow is one row of the shares table. It is a PACKAGE-LEVEL type, not a function-local struct,
// so the render test constructs the exact shape the handler passes: this template reads .System and
// .Available, and a field present in one and missing from the other is a render-time 500 that no
// handler test would catch (the template-gate class this project has hit four times).
type ShareRow struct {
	Name      string
	Path      string
	ReadOnly  bool
	Offsite   bool
	System    bool // controller-owned (R-75): no delete button, and the handler refuses it anyway
	Available bool
}

// sharingPageData assembles the „Megosztás" page state.
func (s *Server) sharingPageData() map[string]interface{} {
	data := s.settingsBaseData("sharing", "Hálózati megosztás")
	smb := s.settings.GetSMBSettings()
	data["SMBEnabled"] = smb.Enabled
	data["SMBServerName"] = smb.EffectiveServerName()
	data["SMBUserSet"] = smb.UserSet
	data["SMBRunning"] = s.stackMgr != nil && s.stackMgr.SambaRunning()

	// „Csatlakozás a megosztáshoz" (v0.151.0, S-2): the page has always shown the configured NAME and
	// never an address, so a customer whose network does not resolve the name had nothing to fall
	// back on and guessed — which is how this diagnosis started, with the Proxmox HOST's IP typed
	// into Finder (DIAG-sharing-2026-07-20.md). Derived FRESH on every render and cached nowhere:
	// the guest holds this address by DHCP, so a stored copy is a copy that eventually misdirects
	// people (S-5). "" simply omits the line — an address-less page beats a wrong address.
	if smb.Enabled {
		data["SMBDirectAddress"] = s.sambaLANAddress()
	}

	var rows []ShareRow
	for _, sh := range s.settings.GetSMBShares() {
		fi, err := os.Stat(sh.Path)
		rows = append(rows, ShareRow{
			Name: sh.Name, Path: sh.Path, ReadOnly: sh.ReadOnly, Offsite: sh.Offsite,
			System:    sh.System,
			Available: err == nil && fi.IsDir(),
		})
	}
	data["SMBShares"] = rows

	// Storage roots offered for the "new folder on a drive" flow.
	var roots []map[string]string
	for _, sp := range s.settings.GetStoragePaths() {
		if sp.Decommissioned || sp.Disconnected {
			continue
		}
		label := sp.Label
		if label == "" {
			label = filepath.Base(sp.Path)
		}
		roots = append(roots, map[string]string{"Path": sp.Path, "Label": label})
	}
	data["StorageRoots"] = roots

	// R-7b: per-tier backup truth. Until R-7b the „Felhőmentés" toggle promised a protection the
	// engines did not deliver; these two lines are what makes the promise checkable by the customer
	// rather than taken on faith. Amber ONLY on deviation — a green tier says nothing at all beyond
	// its timestamp, so the page stays quiet when everything is fine.
	if s.backupMgr != nil {
		if cd := s.backupMgr.SharesTier2Status(); cd != nil {
			data["SharesTier2Status"] = cd.LastStatus
			data["SharesTier2LastRun"] = cd.LastRun
			data["SharesTier2Warning"] = cd.LastWarning
			data["SharesTier2Error"] = cd.LastError
			data["SharesTier2Dest"] = cd.DestinationPath
		}
		if lastRun, status, count, ok := s.backupMgr.SharesOffsiteStatus(); ok {
			data["SharesOffsiteStatus"] = status
			data["SharesOffsiteLastRun"] = lastRun
			data["SharesOffsiteCount"] = count
		}
		data["SharesRestoreReady"] = s.backupMgr.SharesScratchReady()
	}
	return data
}

// sambaLANAddress resolves the connect-address seam. Never cached at this level either — the seam
// exists so tests can supply an address without docker, not so anyone can memoize one.
func (s *Server) sambaLANAddress() string {
	if s.sambaAddrFn != nil {
		return s.sambaAddrFn()
	}
	if s.stackMgr == nil {
		return ""
	}
	return s.stackMgr.SambaLANAddress()
}

// guestGateway resolves the guest's default gateway for the Hálózat card (R-66) — the sibling of
// sambaLANAddress with the identical contract: live per render, never stored, "" = the row shows
// „—". The read goes through the samba-container netns door (stacks/guestnet.go) because the
// controller's OWN /proc/net/route answers for the docker bridge (172.x) — the S-2 wrong answer.
func (s *Server) guestGateway() string {
	if s.guestGatewayFn != nil {
		return s.guestGatewayFn()
	}
	if s.stackMgr == nil {
		return ""
	}
	return s.stackMgr.GuestGateway()
}

// guestNetSnapshot resolves the Debug dump's network section (R-66); same seam shape.
func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot {
	if s.guestNetFn != nil {
		return s.guestNetFn()
	}
	if s.stackMgr == nil {
		return stacks.GuestNetSnapshot{Errors: map[string]string{
			"interfaces": "stack manager unavailable",
			"route":      "stack manager unavailable",
			"dns":        "stack manager unavailable",
		}}
	}
	return s.stackMgr.GuestNetSnapshot()
}

func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
	data := s.sharingPageData()
	if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
		data["Flash"] = f
	}
	s.executeTemplate(w, r, "sharing", data)
}

// sharingRedirect returns to the page with a Hungarian flash.
func sharingRedirect(w http.ResponseWriter, r *http.Request, flash string) {
	http.Redirect(w, r, "/sharing?flash="+urlQueryEscape(flash), http.StatusSeeOther)
}

func urlQueryEscape(s string) string {
	return strings.NewReplacer(" ", "+", "&", "%26", "?", "%3F", "#", "%23").Replace(s)
}

// sharingEnableHandler toggles the feature and updates the server name (POST /sharing/enable).
func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	enable := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true"
	name := strings.TrimSpace(r.FormValue("server_name"))

	if name != "" {
		if err := settings.ValidateSMBServerName(name); err != nil {
			sharingRedirect(w, r, err.Error())
			return
		}
		if err := s.settings.SetSMBServerName(name); err != nil {
			sharingRedirect(w, r, "A mentés nem sikerült.")
			return
		}
	}
	if err := s.settings.SetSMBEnabled(enable); err != nil {
		sharingRedirect(w, r, "A mentés nem sikerült.")
		return
	}
	if !enable {
		if err := s.stackMgr.DisableSamba(); err != nil {
			s.logger.Printf("[WARN] [sharing] disable failed: %v", err)
		}
		sharingRedirect(w, r, "A hálózati megosztás kikapcsolva. A mappák és a fájlok megmaradtak.")
		return
	}
	// R-75: the canonical drop-zone share exists whenever sharing is ON — and NEVER before. Enabling
	// sharing is the customer's decision (it puts SMB on the household LAN and demands a household
	// password); deploying a drop-zone app must not trigger it. "Mandatory" here means "always present
	// once sharing is on", not "turns sharing on".
	if err := s.ensureImportShare(); err != nil {
		s.logger.Printf("[WARN] [sharing] could not ensure the import share: %v", err)
	}

	// v0.147.0 (4b): the bring-up runs DETACHED and the page polls it. Synchronously it was a form
	// post that hung for minutes on a first-enable image pull and then flashed „Beállítás mentve."
	// regardless of whether anything actually came up.
	if !s.startSambaEnsure() {
		sharingRedirect(w, r, "A megosztási szolgáltatás előkészítése már folyamatban van.")
		return
	}
	sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
}

// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
// channels in one envelope, and keeping them apart is the whole point of this handler:
//
//	phase   — the ensure JOB. The client treats a terminal `running` as an EDGE ("the bring-up I was
//	          watching just succeeded") and reloads once to repaint the server-rendered badge.
//	running — the service LEVEL, straight from the liveness probe. True whenever the container is up,
//	          with or without a job, and it is what makes a page loaded after the job finished (or
//	          after a controller restart, when the in-memory job is gone) still show the truth.
//
// v0.147.0 coerced `idle` → `running` here so that liveness could never be contradicted by a missing
// job. That duty belongs to — and was already discharged by — the `running` field beside it; on the
// phase channel the same value reads as a fresh terminal edge, so the client reloaded on the FIRST
// poll of every steady-state page load and the page looped at ~1.2s forever
// (felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, S-1). The coercion is gone: no job,
// no edge. See consumeIfRunning for the other half — a REAL bring-up must be reported exactly once.
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
	phase := sambaPhaseIdle
	errMsg := ""
	if job := s.sambaEnsure.consumeIfRunning(); job != nil {
		phase, errMsg = job.Phase, job.Error
	}
	running := s.stackMgr != nil && s.stackMgr.SambaRunning()
	writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
		"phase": phase, "error": errMsg, "running": running,
	})
}

// sharingPasswordHandler sets the household SMB password (POST /sharing/password).
// SECRET: the password is read from the form and handed straight to the stacks layer, which puts it
// on smbpasswd's stdin. It is never logged and never persisted.
func (s *Server) sharingPasswordHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	pw := r.FormValue("smb_password")
	pw2 := r.FormValue("smb_password_confirm")
	if len(pw) < 8 {
		sharingRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.")
		return
	}
	if pw != pw2 {
		sharingRedirect(w, r, "A két jelszó nem egyezik.")
		return
	}
	if err := s.stackMgr.SetSMBPassword(pw); err != nil {
		s.logger.Printf("[ERROR] [sharing] password apply failed: %v", err)
		sharingRedirect(w, r, "A jelszó beállítása nem sikerült.")
		return
	}
	// Setting the password is the moment the stack ACTUALLY first comes up: with UserSet false,
	// reconcile deliberately deploys nothing, so on a fresh box this — not the enable toggle — is
	// where the image pull happens. Same detached job, same card.
	if !s.startSambaEnsure() {
		sharingRedirect(w, r, "Megosztási jelszó beállítva. Az előkészítés már folyamatban van.")
		return
	}
	sharingRedirect(w, r, "Megosztási jelszó beállítva. A megosztási szolgáltatás előkészítése folyamatban…")
}

// sharingShareCreateHandler creates a share (POST /sharing/shares). Either a NEW folder under
// <storage>/shares/ or an EXISTING folder picked in the browser — both go through the same guard.
func (s *Server) sharingShareCreateHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	mode := r.FormValue("mode") // "new" | "existing"
	readOnly := r.FormValue("read_only") == "on"

	if err := settings.ValidateSMBShareName(name); err != nil {
		sharingRedirect(w, r, err.Error())
		return
	}

	var target string
	switch mode {
	case "new":
		root, err := s.sharingResolveStorageRoot(r.FormValue("storage_root"))
		if err != nil {
			sharingRedirect(w, r, errNotShareable.Error())
			return
		}
		dir := filepath.Join(root, "shares", name)
		// The name is already NetBIOS-validated (no slash/dot), but assert containment anyway —
		// a join that escaped its root must never reach MkdirAll.
		if !pathWithin(dir, root) {
			sharingRedirect(w, r, errNotShareable.Error())
			return
		}
		if err := os.MkdirAll(dir, 0o775); err != nil {
			s.logger.Printf("[ERROR] [sharing] mkdir failed: %v", err)
			sharingRedirect(w, r, "A mappa létrehozása nem sikerült.")
			return
		}
		// uid:gid 1000 so apps and both backup tiers see the same ownership as SMB writes.
		if err := os.Chown(dir, 1000, 1000); err != nil {
			s.logger.Printf("[WARN] [sharing] chown 1000:1000 failed: %v", err)
		}
		target = dir
	case "existing":
		resolved, err := s.sharingResolvePath(r.FormValue("path"))
		if err != nil {
			sharingRedirect(w, r, errNotShareable.Error())
			return
		}
		target = resolved
	default:
		sharingRedirect(w, r, errNotShareable.Error())
		return
	}

	share := settings.SMBShare{
		Name:      name,
		Path:      target,
		ReadOnly:  readOnly,
		Offsite:   true, // [R4] new shares default to mandatory (offsite + tier-2)
		CreatedAt: time.Now().UTC().Format(time.RFC3339),
	}
	if err := s.settings.AddSMBShare(share); err != nil {
		sharingRedirect(w, r, err.Error())
		return
	}
	if err := s.stackMgr.ReconcileSamba(); err != nil {
		s.logger.Printf("[WARN] [sharing] reconcile after share create failed: %v", err)
	}
	sharingRedirect(w, r, "A megosztás létrehozva.")
}

// sharingShareDeleteHandler removes a share (POST /sharing/shares/delete). CONFIG ONLY — by
// construction there is no filesystem removal anywhere in this feature.
func (s *Server) sharingShareDeleteHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	// SERVER-SIDE refusal for controller-owned shares (R-75), BEFORE any mutation. The template also
	// omits the button; both are required and they prove different things — a render gate is not
	// enforcement, and a handler check is not reachability (the v0.70.1 ghost-delete lesson).
	for _, sh := range s.settings.GetSMBShares() {
		if strings.EqualFold(sh.Name, name) && sh.System {
			sharingRedirect(w, r, "Ez a megosztás a rendszer része, nem törölhető.")
			return
		}
	}
	if err := s.settings.RemoveSMBShare(name); err != nil {
		sharingRedirect(w, r, err.Error())
		return
	}
	if err := s.stackMgr.ReconcileSamba(); err != nil {
		s.logger.Printf("[WARN] [sharing] reconcile after share delete failed: %v", err)
	}
	sharingRedirect(w, r, "A megosztás törölve — a mappa és a fájlok megmaradtak.")
}

// sharingShareOffsiteHandler flips a share's „Felhőmentés" toggle (POST /sharing/shares/offsite).
func (s *Server) sharingShareOffsiteHandler(w http.ResponseWriter, r *http.Request) {
	_ = r.ParseForm()
	name := strings.TrimSpace(r.FormValue("name"))
	on := r.FormValue("offsite") == "on" || r.FormValue("offsite") == "true"
	if err := s.settings.SetSMBShareOffsite(name, on); err != nil {
		sharingRedirect(w, r, err.Error())
		return
	}
	sharingRedirect(w, r, "Beállítás mentve.")
}

// ServeSharingAPI dispatches the /api/sharing/* XHR endpoints. Registered on the mux in main.go
// behind RequireAuth+CsrfProtect (the /api/ subtree is claimed there, NOT in the web ServeHTTP
// switch — a case added there would be shadowed by the apiRouter catch-all and 401).
func (s *Server) ServeSharingAPI(w http.ResponseWriter, r *http.Request) {
	switch {
	case r.URL.Path == "/api/sharing/browse" && r.Method == http.MethodGet:
		s.sharingBrowseHandler(w, r)
	default:
		http.NotFound(w, r)
	}
}

// sharingBrowseHandler is the folder picker (GET /api/sharing/browse?path=). With no path it lists
// the registered live storage roots; otherwise the sub-DIRECTORIES of a guard-approved path, sorted.
// Deny-listed children are omitted so the picker never offers an unshareable folder.
func (s *Server) sharingBrowseHandler(w http.ResponseWriter, r *http.Request) {
	type entry struct {
		Name string `json:"name"`
		Path string `json:"path"`
	}
	resp := struct {
		Path    string  `json:"path"`
		Parent  string  `json:"parent,omitempty"`
		Entries []entry `json:"entries"`
		Error   string  `json:"error,omitempty"`
	}{Entries: []entry{}}

	raw := r.URL.Query().Get("path")
	if strings.TrimSpace(raw) == "" {
		for _, sp := range s.settings.GetStoragePaths() {
			if sp.Decommissioned || sp.Disconnected {
				continue
			}
			label := sp.Label
			if label == "" {
				label = filepath.Base(sp.Path)
			}
			resp.Entries = append(resp.Entries, entry{Name: label, Path: sp.Path})
		}
		sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name })
		writeSharingJSON(w, http.StatusOK, resp)
		return
	}

	dir, err := s.sharingResolvePath(raw)
	if err != nil {
		resp.Error = errNotShareable.Error()
		writeSharingJSON(w, http.StatusBadRequest, resp)
		return
	}
	resp.Path = dir
	if root, ok := s.sharingOwningRoot(dir); ok && dir != root {
		resp.Parent = filepath.Dir(dir)
	}

	items, rerr := os.ReadDir(dir)
	if rerr != nil {
		resp.Error = "A mappa nem érhető el."
		writeSharingJSON(w, http.StatusBadRequest, resp)
		return
	}
	root, _ := s.sharingOwningRoot(dir)
	denied := stacks.SharingDeniedRoots(root)
	for _, it := range items {
		if !it.IsDir() || strings.HasPrefix(it.Name(), ".") {
			continue
		}
		child := filepath.Join(dir, it.Name())
		skip := false
		for _, d := range denied {
			if pathWithin(child, d) {
				skip = true
				break
			}
		}
		if skip {
			continue
		}
		resp.Entries = append(resp.Entries, entry{Name: it.Name(), Path: child})
	}
	sort.Slice(resp.Entries, func(i, j int) bool { return resp.Entries[i].Name < resp.Entries[j].Name })
	writeSharingJSON(w, http.StatusOK, resp)
}

func writeSharingJSON(w http.ResponseWriter, code int, v interface{}) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.WriteHeader(code)
	if err := json.NewEncoder(w).Encode(v); err != nil {
		fmt.Fprintf(w, `{"error":"encode"}`)
	}
}

// ensureImportShare creates the controller-owned drop-zone share (R-75) if it is not already there.
// Idempotent, and a no-op when the import root is unresolvable.
//
// It writes to the store DIRECTLY rather than going through sharingResolvePath: that guard validates
// paths a CUSTOMER supplied through the picker, and refuses anything outside a registered storage
// root. The system drive is deliberately not registered (registering it would make a 50 GB volume
// holding the recovery units a customer-visible drive, a deploy target and a wipe candidate), so the
// guard would refuse this path — correctly, for customer input. A controller-generated constant is a
// different trust class.
//
// Offsite is FALSE: the drop-zone is class `excluded` data, and shipping an inbox offsite would
// contradict the class the backup engines already act on.
func (s *Server) ensureImportShare() error {
	if s.stackMgr == nil {
		return nil
	}
	root := s.stackMgr.GetImportRoot()
	if root == "" {
		return nil
	}
	for _, sh := range s.settings.GetSMBShares() {
		if strings.EqualFold(sh.Name, settings.SystemImportShareName) {
			return nil // already present
		}
	}
	if err := s.stackMgr.EnsureImportRoot(); err != nil {
		return err
	}
	return s.settings.AddSMBShare(settings.SMBShare{
		Name:     settings.SystemImportShareName,
		Path:     root,
		ReadOnly: false,
		Offsite:  false,
		System:   true,
	})
}
