package sync

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"log"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
	"sync"
	"time"

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

// gitCmdTimeout bounds each individual git subprocess (campaign finding F3, 2026-07-06):
// without a deadline, a hung remote parks the sync goroutine inside cmd.Run() — the doSync
// defer never runs, `syncing` stays true, and every manual + periodic sync is refused with
// "Szinkronizálás már folyamatban" until a controller restart. 120s is generous for the
// shallow catalog clone/fetch.
const gitCmdTimeout = 120 * time.Second

// Syncer handles periodic git sync of the app catalog to the local stacks directory.
type Syncer struct {
	cfg          *config.Config
	logger       *log.Logger
	cacheDir     string // local git clone
	rescanFn     func() error
	postSyncHook func(updated []string) // called after sync with names of updated stacks
	mu           sync.Mutex
	lastSync     time.Time
	lastErr      error
	syncing      bool
	stopCh       chan struct{}
	stopOnce     sync.Once
}

// SyncStatus holds information about the last sync operation.
type SyncStatus struct {
	LastSync   time.Time `json:"last_sync"`
	LastStatus string    `json:"last_status"` // "ok", "error", "disabled", "never"
	LastError  string    `json:"last_error,omitempty"`
	Syncing    bool      `json:"syncing"`
}

// SyncResult holds the result of a single sync operation.
type SyncResult struct {
	OK       bool     `json:"ok"`
	NewApps  []string `json:"new_apps,omitempty"`
	Updated  []string `json:"updated,omitempty"`
	Message  string   `json:"message"`
}

// New creates a new Syncer. rescanFn is called after a successful sync to trigger ScanStacks().
// postSyncHook is called with names of updated stacks (may be nil).
func New(cfg *config.Config, logger *log.Logger, rescanFn func() error, postSyncHook func([]string)) *Syncer {
	cacheDir := filepath.Join(cfg.Paths.DataDir, "catalog-cache")
	return &Syncer{
		cfg:          cfg,
		logger:       logger,
		cacheDir:     cacheDir,
		rescanFn:     rescanFn,
		postSyncHook: postSyncHook,
		stopCh:       make(chan struct{}),
	}
}

// isDebug returns true if the logging level is set to "debug".
func (s *Syncer) isDebug() bool { return s.cfg.Logging.Level == "debug" }

// maskRepoURL masks credentials in a git URL for safe logging.
// e.g., "https://user:token@host/path" → "https://user:***@host/path"
var reURLCreds = regexp.MustCompile(`(https?://)([^:]+):([^@]+)@`)

func maskRepoURL(url string) string {
	return reURLCreds.ReplaceAllString(url, "${1}${2}:***@")
}

// Start begins the periodic sync loop. Call Stop() to terminate.
func (s *Syncer) Start() {
	if s.cfg.Git.RepoURL == "" {
		s.logger.Println("[WARN] [sync] Git repo URL is empty — sync disabled (manual mode)")
		return
	}

	interval, err := time.ParseDuration(s.cfg.Git.SyncInterval)
	if err != nil {
		s.logger.Printf("[WARN] [sync] Invalid sync_interval %q, defaulting to 15m", s.cfg.Git.SyncInterval)
		interval = 15 * time.Minute
	}

	s.logger.Printf("[INFO] [sync] Starting catalog sync (repo: %s, interval: %s)", s.cfg.Git.RepoURL, interval)

	// Initial sync on startup
	go func() {
		result := s.doSync()
		s.logger.Printf("[INFO] [sync] Initial sync: %s", result.Message)
	}()

	go func() {
		ticker := time.NewTicker(interval)
		defer ticker.Stop()
		for {
			select {
			case <-s.stopCh:
				s.logger.Println("[INFO] [sync] Sync loop stopped")
				return
			case <-ticker.C:
				result := s.doSync()
				s.logger.Printf("[INFO] [sync] Periodic sync: %s", result.Message)
			}
		}
	}()
}

// Stop terminates the periodic sync loop. Safe to call multiple times.
func (s *Syncer) Stop() {
	s.stopOnce.Do(func() {
		close(s.stopCh)
	})
}

// TriggerSync performs an immediate sync. Returns the result.
// Debounce: refuses if last sync was less than 30 seconds ago.
func (s *Syncer) TriggerSync() SyncResult {
	if s.cfg.Git.RepoURL == "" {
		return SyncResult{OK: false, Message: "Git sync is disabled (no repo_url configured)"}
	}

	s.mu.Lock()
	if s.syncing {
		s.mu.Unlock()
		return SyncResult{OK: false, Message: "Szinkronizálás már folyamatban"}
	}
	if time.Since(s.lastSync) < 30*time.Second {
		s.mu.Unlock()
		return SyncResult{OK: false, Message: "Túl gyakori szinkronizálás — várj 30 másodpercet"}
	}
	s.syncing = true
	s.mu.Unlock()

	return s.doSync()
}

// Status returns the current sync status.
func (s *Syncer) Status() SyncStatus {
	s.mu.Lock()
	defer s.mu.Unlock()

	status := SyncStatus{
		LastSync: s.lastSync,
		Syncing:  s.syncing,
	}

	if s.cfg.Git.RepoURL == "" {
		status.LastStatus = "disabled"
	} else if s.lastSync.IsZero() {
		status.LastStatus = "never"
	} else if s.lastErr != nil {
		status.LastStatus = "error"
		status.LastError = s.lastErr.Error()
	} else {
		status.LastStatus = "ok"
	}

	return status
}

// doSync performs the actual git clone/pull + file copy.
func (s *Syncer) doSync() SyncResult {
	s.mu.Lock()
	s.syncing = true
	s.mu.Unlock()

	defer func() {
		s.mu.Lock()
		s.syncing = false
		s.mu.Unlock()
	}()

	result := SyncResult{OK: true}

	s.logger.Printf("[INFO] [sync] Starting catalog sync")

	// Step 1: Clone or pull
	if err := s.gitCloneOrPull(); err != nil {
		s.logger.Printf("[ERROR] [sync] Catalog sync failed: %v", err)
		s.mu.Lock()
		s.lastErr = err
		s.lastSync = time.Now()
		s.mu.Unlock()
		return SyncResult{OK: false, Message: fmt.Sprintf("Git hiba: %v", err)}
	}

	// Step 2: Copy templates to stacks dir
	newApps, updated, err := s.copyTemplates()
	if err != nil {
		s.logger.Printf("[ERROR] [sync] Catalog sync failed: %v", err)
		s.mu.Lock()
		s.lastErr = err
		s.lastSync = time.Now()
		s.mu.Unlock()
		return SyncResult{OK: false, Message: fmt.Sprintf("Másolási hiba: %v", err)}
	}

	result.NewApps = newApps
	result.Updated = updated

	// Step 3: Trigger rescan if anything changed
	if len(newApps) > 0 || len(updated) > 0 {
		if err := s.rescanFn(); err != nil {
			s.logger.Printf("[WARN] [sync] Rescan after sync failed: %v", err)
		}
	}

	// Step 4: Inject missing deploy fields for updated stacks
	if len(updated) > 0 && s.postSyncHook != nil {
		if s.isDebug() {
			s.logger.Printf("[DEBUG] [sync] Post-sync hook: triggering missing field injection for %d stack(s): %v", len(updated), updated)
		}
		s.postSyncHook(updated)
	}

	// Build message
	parts := []string{}
	if len(newApps) > 0 {
		parts = append(parts, fmt.Sprintf("új: %s", strings.Join(newApps, ", ")))
	}
	if len(updated) > 0 {
		parts = append(parts, fmt.Sprintf("frissítve: %s", strings.Join(updated, ", ")))
	}
	if len(parts) == 0 {
		result.Message = "Sablonok naprakészek — nincs változás"
	} else {
		result.Message = "Sablonok frissítve — " + strings.Join(parts, "; ")
	}

	s.logger.Printf("[INFO] [sync] Catalog sync complete")

	s.mu.Lock()
	s.lastErr = nil
	s.lastSync = time.Now()
	s.mu.Unlock()

	return result
}

// gitCloneOrPull clones the repo if not yet cloned, or pulls latest changes.
func (s *Syncer) gitCloneOrPull() error {
	if err := os.MkdirAll(filepath.Dir(s.cacheDir), 0755); err != nil {
		return fmt.Errorf("creating cache parent dir: %w", err)
	}

	gitDir := filepath.Join(s.cacheDir, ".git")
	if _, err := os.Stat(gitDir); os.IsNotExist(err) {
		// Clone
		s.logger.Printf("[INFO] [sync] Cloning %s → %s", s.cfg.Git.RepoURL, s.cacheDir)
		args := []string{"clone", "--depth", "1", "--branch", s.cfg.Git.Branch}
		repoURL := s.buildRepoURL()
		args = append(args, repoURL, s.cacheDir)
		if s.isDebug() {
			s.logger.Printf("[DEBUG] [sync] git clone URL: %s, branch: %s, cacheDir: %s", maskRepoURL(repoURL), s.cfg.Git.Branch, s.cacheDir)
		}
		return s.runGit(args...)
	}

	// Remove stale git lock files left behind by interrupted operations
	s.removeGitLockFiles()

	// Pull
	s.logger.Printf("[INFO] [sync] Pulling latest from %s (branch: %s)", s.cfg.Git.RepoURL, s.cfg.Git.Branch)
	if s.isDebug() {
		s.logger.Printf("[DEBUG] [sync] git fetch --depth 1 origin %s in %s", s.cfg.Git.Branch, s.cacheDir)
	}
	if err := s.gitCmd(s.cacheDir, "fetch", "--depth", "1", "origin", s.cfg.Git.Branch); err != nil {
		return fmt.Errorf("git fetch: %w", err)
	}
	if err := s.gitCmd(s.cacheDir, "reset", "--hard", "origin/"+s.cfg.Git.Branch); err != nil {
		return fmt.Errorf("git reset: %w", err)
	}
	return nil
}

// removeGitLockFiles removes stale .git/*.lock files that may have been left
// behind if a previous git operation was interrupted (e.g. container restart).
// These lock files prevent all subsequent git operations from succeeding.
func (s *Syncer) removeGitLockFiles() {
	gitDir := filepath.Join(s.cacheDir, ".git")
	lockFiles := []string{"index.lock", "shallow.lock", "HEAD.lock"}
	for _, name := range lockFiles {
		lockPath := filepath.Join(gitDir, name)
		if _, err := os.Stat(lockPath); err == nil {
			s.logger.Printf("[WARN] [sync] Removing stale lock file: %s", lockPath)
			if err := os.Remove(lockPath); err != nil {
				s.logger.Printf("[WARN] [sync] Failed to remove lock file %s: %v", lockPath, err)
			}
		}
	}
}

// buildRepoURL constructs the repo URL with optional auth credentials.
func (s *Syncer) buildRepoURL() string {
	url := s.cfg.Git.RepoURL
	if s.cfg.Git.Username != "" && s.cfg.Git.Token != "" {
		// Inject credentials into HTTPS URL: https://user:token@host/path
		url = strings.Replace(url, "https://", fmt.Sprintf("https://%s:%s@", s.cfg.Git.Username, s.cfg.Git.Token), 1)
	}
	return url
}

// copyTemplates copies docker-compose.yml and .felhom.yml from the catalog cache
// to the stacks directory. Never overwrites app.yaml.
func (s *Syncer) copyTemplates() (newApps []string, updated []string, err error) {
	templatesDir := filepath.Join(s.cacheDir, "templates")
	entries, err := os.ReadDir(templatesDir)
	if err != nil {
		return nil, nil, fmt.Errorf("reading templates dir: %w", err)
	}

	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}

		appName := entry.Name()
		srcDir := filepath.Join(templatesDir, appName)
		dstDir := filepath.Join(s.cfg.Paths.StacksDir, appName)

		isNew := false
		if _, err := os.Stat(dstDir); os.IsNotExist(err) {
			isNew = true
			if err := os.MkdirAll(dstDir, 0755); err != nil {
				s.logger.Printf("[WARN] [sync] Failed to create stack dir %s: %v", dstDir, err)
				continue
			}
		}

		// Files to sync (only template files, never app.yaml)
		syncFiles := []string{"docker-compose.yml", ".felhom.yml"}
		anyChanged := false

		for _, filename := range syncFiles {
			src := filepath.Join(srcDir, filename)
			dst := filepath.Join(dstDir, filename)

			if _, err := os.Stat(src); os.IsNotExist(err) {
				if s.isDebug() {
					s.logger.Printf("[DEBUG] [sync] %s/%s: source not found, skipping", appName, filename)
				}
				continue
			}

			changed, err := copyIfChanged(src, dst)
			if err != nil {
				s.logger.Printf("[WARN] [sync] Failed to copy catalog file %s/%s: %v", appName, filename, err)
				continue
			}
			if changed {
				anyChanged = true
				s.logger.Printf("[INFO] [sync] Updated %s/%s", appName, filename)
				if s.isDebug() {
					s.logFileHashes(appName, filename, src, dst)
				}
			} else if s.isDebug() {
				s.logger.Printf("[DEBUG] [sync] %s/%s: hash match, skipped", appName, filename)
			}
		}

		if isNew {
			newApps = append(newApps, appName)
		} else if anyChanged {
			updated = append(updated, appName)
		}
	}

	return newApps, updated, nil
}

// logFileHashes logs the source and destination file hashes for debugging.
func (s *Syncer) logFileHashes(appName, filename, src, dst string) {
	srcData, err := os.ReadFile(src)
	if err != nil {
		return
	}
	srcHash := sha256.Sum256(srcData)
	dstData, err := os.ReadFile(dst)
	if err != nil {
		s.logger.Printf("[DEBUG] [sync] %s/%s: src=%s, dst=new file", appName, filename, hex.EncodeToString(srcHash[:8]))
		return
	}
	dstHash := sha256.Sum256(dstData)
	s.logger.Printf("[DEBUG] [sync] %s/%s: src=%s, dst=%s (changed)", appName, filename, hex.EncodeToString(srcHash[:8]), hex.EncodeToString(dstHash[:8]))
}

// copyIfChanged copies src to dst only if the content differs.
// Returns true if the file was actually written.
func copyIfChanged(src, dst string) (bool, error) {
	srcData, err := os.ReadFile(src)
	if err != nil {
		return false, fmt.Errorf("reading %s: %w", src, err)
	}

	// Check if dst exists and has same content
	dstData, err := os.ReadFile(dst)
	if err == nil {
		srcHash := sha256.Sum256(srcData)
		dstHash := sha256.Sum256(dstData)
		if srcHash == dstHash {
			return false, nil // No change
		}
	}

	if err := os.WriteFile(dst, srcData, 0644); err != nil {
		return false, fmt.Errorf("writing %s: %w", dst, err)
	}
	return true, nil
}

// runGit executes a git command with the given args under the standard per-command deadline.
func (s *Syncer) runGit(args ...string) error {
	return s.gitCmd("", args...)
}

// gitCmd runs one git command in dir under a fresh gitCmdTimeout deadline (per-command,
// not per-sync — a multi-step sync gets a full budget for each subprocess).
func (s *Syncer) gitCmd(dir string, args ...string) error {
	ctx, cancel := context.WithTimeout(context.Background(), gitCmdTimeout)
	defer cancel()
	return s.runGitInDir(ctx, dir, args...)
}

// runGitInDir executes a git command in the specified directory, killed when ctx expires.
func (s *Syncer) runGitInDir(ctx context.Context, dir string, args ...string) error {
	cmd := exec.CommandContext(ctx, "git", args...)
	if dir != "" {
		cmd.Dir = dir
	}

	var stderr bytes.Buffer
	cmd.Stdout = io.Discard
	cmd.Stderr = &stderr

	s.logger.Printf("[DEBUG] [sync] Running: git %s", maskRepoURL(strings.Join(args, " ")))

	if err := cmd.Run(); err != nil {
		if ctxErr := ctx.Err(); ctxErr != nil {
			// Subprocess was killed by the deadline/cancellation — name it explicitly so the
			// failure is diagnosable from SyncResult/logs (never a silent hang).
			return fmt.Errorf("git %s: %v (subprocess killed at the %s deadline): %w\nstderr: %s",
				maskRepoURL(strings.Join(args, " ")), ctxErr, gitCmdTimeout, err, stderr.String())
		}
		return fmt.Errorf("git %s: %w\nstderr: %s", maskRepoURL(strings.Join(args, " ")), err, stderr.String())
	}
	return nil
}
