package backup

import (
	"encoding/json"
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"time"

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

// SMB share backup — R-7b, Model B′ (Viktor's ruling 2026-07-18). Share data enters the live backup
// runs through a SIBLING shares source: additive job/leg code that reuses the proven primitives
// (tier-2 mirror seam, restic wrappers, soft-quota gate, status recording) while leaving every
// per-app engine path BYTE-IDENTICAL. This file holds the piece both tiers share: the PAYLOAD — the
// share DEFINITIONS plus the SMB password hash — so a DR rebuild restores the files, the share
// configuration, and the household credential, not just the bytes on disk.
//
// Why a payload at all: the share folders are plain customer directories; copying them protects the
// data but loses everything that made them shares. Without the manifest a restore leaves the customer
// with their files back and an empty „Megosztás" page.

const (
	// SharesPseudoStack is the RESERVED key the shares source occupies wherever the per-app engines
	// key by stack name: the restic tag, the tier-2 dest root (backups/secondary/_shares/…), and the
	// CrossDriveBackup status record. It is NEVER shown to the customer — every UI/notification
	// boundary maps it through SharesDisplayName (see DisplayStackName).
	//
	// RESERVED-NAME VERIFICATION (R-7b): the claim that slice-1 validation already excludes a leading
	// underscore is FALSE for shares — settings.nbNameRe starts with [A-Za-z0-9_], so „_shares" was an
	// accepted share name. settings.ValidateSMBShareName now rejects the underscore-prefixed namespace
	// outright. Stack names come from the git-synced catalog (not customer input) and so cannot
	// realistically produce „_shares", but RunAllTier2 and RunOffboxBackup skip such a stack with a
	// loud WARN as defense in depth rather than let it clobber the shares tree.
	SharesPseudoStack = "_shares"
	// SharesDisplayName is the Hungarian customer-facing name for the shares source.
	SharesDisplayName = "Megosztások"

	// sharesManifestName is the share-definition document inside the payload.
	sharesManifestName = "_shares-manifest.json"
	// sharesPassdbName is the SECRET-BEARING samba passdb archive inside the payload. It rides the
	// restic repo (encrypted at rest) and the tier-2 staging on the customer's own drives; its bytes
	// and its name never reach a log line at INFO, a report, or a committed file.
	sharesPassdbName = "passdb.tar"
	// sharesPassdbMember is the subtree inside infra.SambaPassdbMount that holds the credential.
	sharesPassdbMember = "private"

	// sharesManifestVersion pins the payload document shape.
	sharesManifestVersion = 1
)

// DisplayStackName maps an engine-internal stack key to the name a customer may see. Today the only
// mapping is the reserved shares pseudo-stack; every other key is its own display name. THIS is the
// single boundary that keeps „_shares" out of the Hungarian UI, alerts and e-mails.
func DisplayStackName(key string) string {
	if key == SharesPseudoStack {
		return SharesDisplayName
	}
	return key
}

// SharesManifest is the restorable share-definition document. It is deliberately timestamp-free at
// the document level so the rendered JSON is BYTE-DETERMINISTIC for an unchanged registry — the
// tier-2 mirror then has nothing to rewrite on a no-op run. Per-share CreatedAt is preserved.
type SharesManifest struct {
	Version    int                 `json:"version"`
	ServerName string              `json:"server_name"`
	Shares     []settings.SMBShare `json:"shares"`
}

// SetSharesPassdbCapturer overrides the samba passdb capture (tests inject a fake so no docker runs).
func (m *Manager) SetSharesPassdbCapturer(fn func() ([]byte, error)) { m.sharesPassdbCapture = fn }

// sharesPassdbCapturer returns the passdb capture seam (nil → the real docker exec).
func (m *Manager) sharesPassdbCapturer() func() ([]byte, error) {
	if m.sharesPassdbCapture != nil {
		return m.sharesPassdbCapture
	}
	return defaultSharesPassdbCapture
}

// defaultSharesPassdbCapture tars the samba private/ subtree out of the running container on stdout.
// Best-effort by contract: a stopped container simply yields an error and the payload ships
// manifest-only (re-setting the SMB password is a cheap UX step; the definitions are the load-bearing
// part). Uses the same `docker exec` shape as stacks.extractInitialCreds.
func defaultSharesPassdbCapture() ([]byte, error) {
	cmd := exec.Command("docker", "exec", infra.SambaContainerName,
		"tar", "cf", "-", "-C", infra.SambaPassdbMount, sharesPassdbMember)
	out, err := cmd.Output()
	if err != nil {
		return nil, fmt.Errorf("passdb capture: %w", err)
	}
	return out, nil
}

// sharesPayloadDir is the staging directory both tiers read. It lives in the controller DATA DIR
// (not on a customer drive) for two reasons: the payload is kilobytes — manifest JSON plus a small
// tdb archive — so the F-A1 rootfs-filler concern does not apply, and a DETERMINISTIC absolute path
// makes the restic snapshot path stable, which is what the restore mapper anchors on. 0700 because
// the passdb archive is secret-bearing.
func (m *Manager) sharesPayloadDir() string {
	return filepath.Join(m.cfg.Paths.DataDir, "shares-payload")
}

// SharesPayloadDir exposes the staging path for the restore mapper and tests.
func (m *Manager) SharesPayloadDir() string { return m.sharesPayloadDir() }

// buildSharesManifest renders the deterministic manifest document from the live registry. Shares are
// sorted case-insensitively by name so an unchanged registry always marshals to identical bytes
// regardless of the order the customer happened to add them in.
func (m *Manager) buildSharesManifest() SharesManifest {
	mf := SharesManifest{Version: sharesManifestVersion, Shares: []settings.SMBShare{}}
	if m.settings == nil {
		return mf
	}
	smb := m.settings.GetSMBSettings()
	mf.ServerName = smb.EffectiveServerName()
	mf.Shares = append(mf.Shares, m.settings.GetSMBShares()...)
	sort.Slice(mf.Shares, func(i, j int) bool {
		a, b := strings.ToLower(mf.Shares[i].Name), strings.ToLower(mf.Shares[j].Name)
		if a != b {
			return a < b
		}
		return mf.Shares[i].Name < mf.Shares[j].Name
	})
	return mf
}

// buildSharesPayload stages the payload and returns its directory. The manifest is ALWAYS written
// (it is the definitions-protection floor that must never regress); the passdb archive is
// best-effort and its absence is a WARN, not an error. Returns the dir plus whether the passdb made
// it in, so callers can report honestly.
func (m *Manager) buildSharesPayload() (dir string, passdbOK bool, err error) {
	dir = m.sharesPayloadDir()
	if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil {
		return "", false, fmt.Errorf("shares payload dir: %w", mkErr)
	}
	// Tighten an inherited-loose mode from an older layout (the passdb archive lives here).
	if chErr := os.Chmod(dir, 0o700); chErr != nil {
		m.logger.Printf("[WARN] [shares] payload dir chmod failed: %v", chErr)
	}

	blob, mErr := json.MarshalIndent(m.buildSharesManifest(), "", "  ")
	if mErr != nil {
		return "", false, fmt.Errorf("shares manifest marshal: %w", mErr)
	}
	blob = append(blob, '\n')
	if wErr := writeFileAtomic(filepath.Join(dir, sharesManifestName), blob, 0o600); wErr != nil {
		return "", false, fmt.Errorf("shares manifest write: %w", wErr)
	}

	passdbPath := filepath.Join(dir, sharesPassdbName)
	tar, pErr := m.sharesPassdbCapturer()()
	switch {
	case pErr != nil || len(tar) == 0:
		// Container down / never deployed. Keep any PREVIOUSLY captured archive rather than deleting
		// it — a stale credential copy is strictly better for DR than none, and the payload stays
		// self-consistent because the manifest carries no password of its own.
		if _, sErr := os.Stat(passdbPath); sErr == nil {
			m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — keeping the previously captured copy: %v", pErr)
			passdbOK = true
		} else {
			m.logger.Printf("[WARN] [shares] passdb capture unavailable (sharing service down?) — payload is manifest-only; the SMB password must be re-set after a restore: %v", pErr)
		}
	default:
		if wErr := writeFileAtomic(passdbPath, tar, 0o600); wErr != nil {
			// Never fatal: definitions protection must not hinge on the credential copy.
			m.logger.Printf("[WARN] [shares] passdb stage failed — payload is manifest-only: %v", wErr)
		} else {
			passdbOK = true
			m.logger.Printf("[DEBUG] [shares] passdb archive staged (%d bytes)", len(tar))
		}
	}
	m.logger.Printf("[INFO] [shares] payload staged: %d share definition(s), credential copy=%v", len(m.buildSharesManifest().Shares), passdbOK)
	return dir, passdbOK, nil
}

// writeFileAtomic writes via tmp + rename at the requested mode (mirrors stacks.sambaWriteAtomic's
// discipline: a crash mid-write can never leave a half-manifest a restore would read).
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
	tmp := path + ".tmp"
	if err := os.WriteFile(tmp, data, mode); err != nil {
		return err
	}
	if err := os.Chmod(tmp, mode); err != nil {
		_ = os.Remove(tmp)
		return err
	}
	if err := os.Rename(tmp, path); err != nil {
		_ = os.Remove(tmp)
		return err
	}
	return nil
}

// classifiedShare is one share the backup sources act on, with its class resolved from the registry
// exactly as stacks.sambaClassifiedBinds does it: Offsite=true ⇒ MANDATORY (offsite + tier-2),
// Offsite=false ⇒ OPTIONAL (tier-2 only). Availability is resolved here too, so BOTH jobs skip a
// dead mount identically (Scenario F).
type classifiedShare struct {
	settings.SMBShare
	mandatory bool
}

// classifiedShares returns the shares both jobs act on, dropping unavailable ones with a loud WARN.
// A share whose owning drive is disconnected/decommissioned, or whose folder simply is not there, is
// NEVER handed to a mirror or a restic argv: mirroring a missing mountpoint would copy an empty dir
// over a good backup, and that is the silently-wrong-restore class this codebase refuses by rule.
func (m *Manager) classifiedShares() []classifiedShare {
	if m.settings == nil {
		return nil
	}
	var out []classifiedShare
	for _, sh := range m.settings.GetSMBShares() {
		if !m.shareBackupAvailable(sh.Path) {
			m.logger.Printf("[WARN] [shares] share skipped — folder unavailable (drive away or path missing): name=%s", sh.Name)
			continue
		}
		out = append(out, classifiedShare{SMBShare: sh, mandatory: sh.Offsite})
	}
	sort.Slice(out, func(i, j int) bool { return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name) })
	return out
}

// shareBackupAvailable mirrors stacks.shareAvailable (the export-time gate) so the backup sources and
// the smb.conf renderer agree on what "available" means. Kept local rather than imported: the backup
// package must not depend on the stacks package.
func (m *Manager) shareBackupAvailable(path string) bool {
	if strings.TrimSpace(path) == "" {
		return false
	}
	if m.settings != nil {
		p := filepath.ToSlash(path)
		for _, sp := range m.settings.GetStoragePaths() {
			root := filepath.ToSlash(sp.Path)
			if p == root || strings.HasPrefix(p, root+"/") {
				if sp.Disconnected || sp.Decommissioned {
					return false
				}
				break
			}
		}
	}
	st, err := os.Stat(path)
	return err == nil && st.IsDir()
}

// sharesEnabled reports whether the shares source has anything to do at all: the feature is on AND at
// least one share is registered. A disabled feature or an empty registry is a clean no-op in both
// jobs — no `_shares` restic group, no empty dest dirs (the zero-shares edge case).
func (m *Manager) sharesEnabled() bool {
	if m.settings == nil {
		return false
	}
	return m.settings.GetSMBSettings().Enabled && len(m.settings.GetSMBShares()) > 0
}

// sharesNow is the timestamp helper for the shares status records (kept in one place so tests that
// assert on recorded status have a single seam to reason about).
func sharesNow() string { return time.Now().Format(time.RFC3339) }
