package localapi

import (
	"context"
	"fmt"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"time"

	"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)

// Network storage (NAS) — Part A1, doc 03 §6 + SPIKE-nas-storage-2026-06-29.md. The agent mounts a NAS
// share HOST-SIDE under /mnt/felhom-drives/<name> (it propagates into the guest via the existing shared
// bind); Docker then binds it into a media container. A NAS is a DISTINCT class from a physical drive:
// it bypasses the disk enroll/eject/decommission/wipe machinery entirely (no durable-id, no SMART). The
// controller registry/UI (A2) drives these endpoints; A1 is the agent foundation.
//
// Self-scoping is unchanged (withGuest): one customer per host, the token's guest authorizes the host's
// network-mount surface. The mount is bulk-userdata role ONLY (role-gated below).

// NetworkStorageOps is the privileged network-mount surface the endpoints need. Satisfied by
// *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent.
type NetworkStorageOps interface {
	EnsureNetworkMount(ctx context.Context, spec storage.NetworkMountSpec) error
	RemoveNetworkMount(ctx context.Context, name string) error
	ListNetworkMounts(ctx context.Context) ([]storage.NetworkMountStatus, error)
}

// defaultSmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band; never in
// git, never in a plaintext registry, never logged — same posture as the rotated Resend key).
const defaultSmbCredsDir = "/var/lib/felhom-agent/smb-creds"

// netStorageAddRequest is POST /netstorage/add. For SMB, username+password are written by the agent to a
// 0600 creds file and NEVER logged or echoed back; for NFS they are ignored (server-side squash handles
// uid mapping). mapped_uid/mapped_gid are the CONTAINER ids (the agent applies the +100000 host offset).
type netStorageAddRequest struct {
	VMID           int    `json:"vmid"` // optional; must match the token's guest if set
	Name           string `json:"name"`
	Protocol       string `json:"protocol"` // "nfs" | "smb"
	Server         string `json:"server"`
	Export         string `json:"export"`              // NFS export path | SMB share name
	MappedUID      int    `json:"mapped_uid"`          // container uid (e.g. 1000)
	MappedGID      int    `json:"mapped_gid"`          // container gid
	IdleTimeoutSec int    `json:"idle_timeout_sec"`    // automount idle-unmount window; 0 → default
	Username       string `json:"username,omitempty"`  // SMB only (secret — written to creds file)
	Password       string `json:"password,omitempty"`  // SMB only (secret — written to creds file)
}

// handleNetStorageAdd installs a NAS share host-side — verify-before-commit (SPIKE-nas-verify).
// Pipeline: decode + scope + role-gate (unchanged) → SYNC fast-fail (spec validation + a 2 s TCP
// pre-probe; an unreachable server is refused with NOTHING installed — Scenario E) → stage SMB creds
// (0600) → EnsureNetworkMount → start the DETACHED verify job → respond {verify:"started"}. The
// caller (controller orchestrator) polls GET /netstorage/verify-status; a failed verify has already
// auto-rolled-back agent-side.
func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.netStorage == nil {
		writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
		return
	}
	var req netStorageAddRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}

	// Role gate (SPIKE Q7): network storage is bulk-userdata ONLY. The mount lands under the
	// network-mount root; refuse anything that would resolve outside the user-data namespace.
	where := s.netMountRoot + "/" + strings.TrimSpace(req.Name)
	if storage.NetworkMountRole(where) != storage.RoleUserData {
		s.logger.Warn("local-api: network mount refused — not a user-data path",
			"name", req.Name, "where", where, "role", storage.NetworkMountRole(where))
		writeErr(w, http.StatusForbidden, "network storage is restricted to the user-data namespace")
		return
	}

	spec := storage.NetworkMountSpec{
		Name:           strings.TrimSpace(req.Name),
		Protocol:       storage.NetworkProtocol(strings.ToLower(strings.TrimSpace(req.Protocol))),
		Server:         strings.TrimSpace(req.Server),
		Export:         strings.TrimSpace(req.Export),
		MappedUID:      req.MappedUID,
		MappedGID:      req.MappedGID,
		IdleTimeoutSec: req.IdleTimeoutSec,
	}

	// SYNC fast-fail 1/2 — full spec validation BEFORE anything is written or installed. For SMB the
	// spec is validated against the CANONICAL creds path (a fixed POSIX constant + the
	// charset-validated name), not the configured dir — the configured dir is agent-controlled, not
	// user input, and EnsureNetworkMount re-validates the real spec anyway. The file itself is
	// written only after every sync check has passed.
	if spec.Protocol == storage.ProtocolSMB {
		if req.Username == "" || req.Password == "" {
			writeErr(w, http.StatusBadRequest, "smb credentials (username + password) are required")
			return
		}
		spec.CredsRef = defaultSmbCredsDir + "/" + spec.Name + ".cred"
	}
	if err := storage.ValidateNetworkMountSpec(spec); err != nil {
		writeErr(w, http.StatusBadRequest, err.Error())
		return
	}

	// Single-flight (Scenario G): claim the one verify slot before any side effect, so two racing
	// adds can never interleave creds/install/verify.
	job := &netVerifyJob{
		JobID:     s.nowFn().UTC().Format("20060102T150405.000Z"),
		Name:      spec.Name,
		Where:     spec.Where(),
		Protocol:  string(spec.Protocol),
		Phase:     netVerifyPhaseRunning,
		StartedAt: s.nowFn().UTC().Format(time.RFC3339),
		UpdatedAt: s.nowFn().UTC().Format(time.RFC3339),
	}
	if !s.tryStartNetVerify(job) {
		writeStatus(w, http.StatusConflict, false, map[string]any{"code": "busy"},
			"a network storage add is already in progress")
		return
	}

	// SYNC fast-fail 2/2 (Scenario E): a dead endpoint is refused in ~2 s, BEFORE any unit or creds
	// file exists. The refusal carries the category code so the UI message is exact.
	if !s.netReachable(spec.Protocol, spec.Server) {
		s.releaseNetVerify(job)
		s.logger.Warn("local-api: network mount refused — endpoint not reachable",
			"name", spec.Name, "server", spec.Server, "proto", spec.Protocol,
			"code", storage.NetVerifyUnreachable)
		writeStatus(w, http.StatusBadGateway, false,
			map[string]any{"code": storage.NetVerifyUnreachable},
			"NAS endpoint not reachable")
		return
	}
	s.logger.Debug("local-api: NAS endpoint pre-probe passed",
		"name", spec.Name, "server", spec.Server, "proto", spec.Protocol)

	// SMB: stage the credentials out-of-band (0600). NFS needs none (server squash).
	if spec.Protocol == storage.ProtocolSMB {
		credsPath, err := s.writeSMBCreds(spec.Name, req.Username, req.Password)
		if err != nil {
			s.releaseNetVerify(job)
			s.logger.Error("local-api: writing SMB credentials failed", "name", spec.Name, "err", err)
			writeErr(w, http.StatusBadRequest, "could not stage SMB credentials")
			return
		}
		spec.CredsRef = credsPath
		s.logger.Debug("local-api: SMB credentials staged", "name", spec.Name, "path", credsPath) // path only, never content
	}

	if err := s.netStorage.EnsureNetworkMount(r.Context(), spec); err != nil {
		s.logger.Error("local-api: network mount add failed", "name", spec.Name, "proto", spec.Protocol, "err", err)
		// Clean up a creds file we staged for a mount that then failed to apply.
		if spec.Protocol == storage.ProtocolSMB {
			s.removeSMBCreds(spec.Name)
		}
		s.releaseNetVerify(job)
		writeErr(w, http.StatusBadGateway, "network mount failed: "+err.Error())
		return
	}

	// Units installed + automount enabled — hand off to the detached verify (it rolls back on fail).
	s.runNetVerify(spec, job)
	s.logger.Info("local-api: network mount installed — verify started", "name", spec.Name,
		"proto", spec.Protocol, "server", spec.Server, "export", spec.Export, "where", spec.Where(),
		"job_id", job.JobID)
	writeOK(w, map[string]any{
		"name":       spec.Name,
		"protocol":   string(spec.Protocol),
		"where":      spec.Where(),
		"host_uid":   spec.HostUID(),
		"host_gid":   spec.HostGID(),
		"guest_path": spec.Where(), // the in-guest path the controller (A2) repoints a media app's data dir to
		"verify":     "started",
		"job_id":     job.JobID,
	})
}

// handleNetStorageList lists configured network mounts + per-share liveness (read-only/benign).
func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.netStorage == nil {
		writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
		return
	}
	mounts, err := s.netStorage.ListNetworkMounts(r.Context())
	if err != nil {
		s.logger.Error("local-api: network mount list failed", "err", err)
		writeErr(w, http.StatusBadGateway, "could not list network mounts")
		return
	}
	if mounts == nil {
		mounts = []storage.NetworkMountStatus{}
	}
	writeOK(w, map[string]any{"vmid": vmid, "network_mounts": mounts})
}

type netStorageRemoveRequest struct {
	VMID int    `json:"vmid"`
	Name string `json:"name"`
}

// handleNetStorageRemove unmounts + removes a network mount and its SMB creds file. It NEVER routes
// through the drive eject/decommission path — a NAS has no device lifecycle to tear down.
func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request, vmid int) {
	if s.netStorage == nil {
		writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host")
		return
	}
	var req netStorageRemoveRequest
	if !decodeBody(w, r, &req) {
		return
	}
	if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) {
		return
	}
	name := strings.TrimSpace(req.Name)
	if name == "" {
		writeErr(w, http.StatusBadRequest, "name is required")
		return
	}
	if err := s.netStorage.RemoveNetworkMount(r.Context(), name); err != nil {
		s.logger.Error("local-api: network mount remove failed", "name", name, "err", err)
		writeErr(w, http.StatusBadGateway, "network mount removal failed: "+err.Error())
		return
	}
	s.removeSMBCreds(name) // best-effort: a NFS mount has no creds file (no-op)
	s.logger.Info("local-api: network mount removed", "name", name)
	writeOK(w, map[string]any{"name": name, "removed": true})
}

// ---- SMB credentials (out-of-band) ---------------------------------------------------------------

// writeSMBCreds writes a 0600 mount.cifs credentials file for the share and returns its path. The file
// is owned by the agent user; root (the systemd-triggered mount.cifs) still reads it. The secret is
// NEVER logged. A missing username/password is an error (SMB needs credentials).
func (s *Server) writeSMBCreds(name, username, password string) (string, error) {
	if username == "" || password == "" {
		return "", fmt.Errorf("smb credentials (username + password) are required")
	}
	if strings.ContainsAny(username, "\n\r") || strings.ContainsAny(password, "\n\r") {
		return "", fmt.Errorf("smb credentials must not contain newlines")
	}
	path := s.smbCredsPath(name)
	if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
		return "", fmt.Errorf("creds dir: %w", err)
	}
	body := "username=" + username + "\npassword=" + password + "\n"
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		return "", fmt.Errorf("writing creds: %w", err)
	}
	return path, nil
}

// removeSMBCreds deletes a share's creds file (best-effort; absent is fine).
func (s *Server) removeSMBCreds(name string) {
	if err := os.Remove(s.smbCredsPath(name)); err == nil {
		s.logger.Debug("local-api: SMB credentials file removed", "name", name, "path", s.smbCredsPath(name))
	}
}

// smbCredsPath computes a share's creds file path (pure — used by validation BEFORE the file exists).
func (s *Server) smbCredsPath(name string) string {
	dir := s.smbCredsDir
	if dir == "" {
		dir = defaultSmbCredsDir
	}
	return filepath.Join(dir, name+".cred")
}
