package api

import (
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"strings"
	"sync"

	"github.com/go-chi/chi/v5"

	"github.com/alexei/tinyforge/internal/auth"
	"github.com/alexei/tinyforge/internal/crypto"
	"github.com/alexei/tinyforge/internal/gitops"
	"github.com/alexei/tinyforge/internal/store"
	"github.com/alexei/tinyforge/internal/workload/plugin"
)

// keyedMutex is a lazily-populated per-key lock. Used to serialize a critical
// section per workload id (the GitOps sync) without a global lock.
type keyedMutex struct {
	mu sync.Mutex
	m  map[string]*sync.Mutex
}

// lock acquires the mutex for key and returns its unlock func.
func (k *keyedMutex) lock(key string) func() {
	k.mu.Lock()
	if k.m == nil {
		k.m = make(map[string]*sync.Mutex)
	}
	mu, ok := k.m[key]
	if !ok {
		mu = &sync.Mutex{}
		k.m[key] = mu
	}
	k.mu.Unlock()

	mu.Lock()
	return mu.Unlock
}

// gitOpsStatusResponse is the single rich payload the GitOps panel reads — it
// folds the file preview, parsed status, and drift into one response so the UI
// makes a single call (no separate /drift round-trip).
type gitOpsStatusResponse struct {
	Eligible   bool                `json:"eligible"`     // source kind supports GitOps
	Enabled    bool                `json:"enabled"`      // opt-in flag on the workload
	Path       string              `json:"path"`         // repo-relative config path
	Status     string              `json:"status"`       // disabled|ok|no_file|fetch_failed|invalid
	Raw        string              `json:"raw"`          // the .tinyforge.yml text, when present
	Message    string              `json:"message"`      // token-redacted detail for non-ok
	CommitSHA  string              `json:"commit_sha"`   // ref the file was read at
	LastSyncAt string              `json:"last_sync_at"` // last successful sync ("" = never)
	Drift      []gitops.DriftEntry `json:"drift"`        // declared fields that differ from live
	DriftCount int                 `json:"drift_count"`
	// ManagedFields lists every source_config key the repo overlay declares
	// (not just the drifting ones) so the UI can lock exactly those fields on
	// the edit form. Populated only when the file parsed (status ok).
	ManagedFields []string `json:"managed_fields"`
}

// getWorkloadGitOps handles GET /api/workloads/{id}/gitops. Read-only; open to
// any authenticated user. When GitOps is enabled it fetches the repo's
// .tinyforge.yml live and computes drift against the stored source_config.
func (s *Server) getWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
	row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
	if !ok {
		return
	}

	resp := gitOpsStatusResponse{
		Eligible:   gitops.IsEligibleSource(row.SourceKind),
		Enabled:    row.GitOpsEnabled,
		Path:       row.GitOpsPath,
		Status:     "disabled",
		LastSyncAt: row.GitOpsLastSyncAt,
		CommitSHA:  row.GitOpsCommitSHA,
		Drift:      []gitops.DriftEntry{},
	}
	if resp.Path == "" {
		resp.Path = ".tinyforge.yml"
	}

	// Only reach out to the repo when GitOps is actually on.
	if row.GitOpsEnabled && resp.Eligible {
		ref, err := s.gitOpsRepoRef(row)
		if err != nil {
			// Decoding/decrypt failure: surface as fetch_failed, never the raw
			// error (it can carry the token / config bytes).
			slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
			resp.Status = string(gitops.StatusFetchFailed)
			resp.Message = "could not read repo settings for this workload"
			respondJSON(w, http.StatusOK, resp)
			return
		}
		res := gitops.Fetch(r.Context(), ref)
		resp.Status = string(res.Status)
		resp.CommitSHA = firstNonEmpty(res.CommitSHA, row.GitOpsCommitSHA)
		resp.Message = res.Message
		if len(res.Raw) > 0 {
			resp.Raw = string(res.Raw)
		}
		if res.Status == gitops.StatusOK {
			drift, derr := gitops.Drift(res.Spec, json.RawMessage(row.SourceConfig), row.SourceKind)
			if derr != nil {
				slog.Warn("gitops: drift", "workload", row.ID, "error", derr)
			} else if drift != nil {
				resp.Drift = drift
			}
			resp.DriftCount = len(resp.Drift)
			resp.ManagedFields = planFields(gitops.BuildPlan(res.Spec, row.SourceKind))
		}
	}

	respondJSON(w, http.StatusOK, resp)
}

// setWorkloadGitOps handles PUT /api/workloads/{id}/gitops. Admin-only.
// Body: {"enabled": bool, "path": string}. Enabling is refused for source
// kinds that aren't git-backed; the path is validated against traversal.
func (s *Server) setWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
	row, ok := s.loadWorkload(w, chi.URLParam(r, "id"))
	if !ok {
		return
	}

	var body struct {
		Enabled bool   `json:"enabled"`
		Path    string `json:"path"`
	}
	if !decodeJSONStrict(w, r, &body) {
		return
	}

	if body.Enabled && !gitops.IsEligibleSource(row.SourceKind) {
		respondError(w, http.StatusBadRequest,
			"GitOps is only available for dockerfile and static sources")
		return
	}

	path := strings.TrimSpace(body.Path)
	if path != "" && !validGitOpsPath(path) {
		respondError(w, http.StatusBadRequest,
			"invalid path: must be a repo-relative file (no \"..\", no leading slash)")
		return
	}

	if err := s.store.SetWorkloadGitOps(row.ID, body.Enabled, path); err != nil {
		slog.Error("gitops: set", "workload", row.ID, "error", err)
		respondError(w, http.StatusInternalServerError, "failed to update GitOps settings")
		return
	}
	if path == "" {
		path = ".tinyforge.yml"
	}
	respondJSON(w, http.StatusOK, map[string]any{"enabled": body.Enabled, "path": path})
}

// syncWorkloadGitOps handles POST /api/workloads/{id}/gitops/sync. Admin-only.
// It fetches the repo's .tinyforge.yml, merges the declared overlay onto the
// live source_config (validate-then-commit), persists it, and records the sync.
// Explicit action only — there is no auto-apply on deploy in v1.
func (s *Server) syncWorkloadGitOps(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "id")
	if id == "" {
		respondError(w, http.StatusBadRequest, "workload id is required")
		return
	}
	// Serialize the whole read→merge→write per workload so two concurrent
	// syncs can't clobber each other (review S5). Load the row INSIDE the lock
	// so each sync merges off the latest persisted config.
	unlock := s.gitopsSync.lock(id)
	defer unlock()

	row, ok := s.loadWorkload(w, id)
	if !ok {
		return
	}
	if !gitops.IsEligibleSource(row.SourceKind) {
		respondError(w, http.StatusBadRequest,
			"GitOps is only available for dockerfile and static sources")
		return
	}
	if !row.GitOpsEnabled {
		respondError(w, http.StatusBadRequest, "enable GitOps for this workload first")
		return
	}

	ref, err := s.gitOpsRepoRef(row)
	if err != nil {
		slog.Warn("gitops: build repo ref", "workload", row.ID, "error", err)
		respondError(w, http.StatusBadGateway, "could not read repo settings for this workload")
		return
	}

	res := gitops.Fetch(r.Context(), ref)
	switch res.Status {
	case gitops.StatusOK:
		// proceed
	case gitops.StatusNoFile:
		respondError(w, http.StatusBadRequest, "no "+ref.Path+" found on branch "+ref.Branch)
		return
	case gitops.StatusInvalid:
		respondError(w, http.StatusBadRequest, "invalid "+ref.Path+": "+res.Message)
		return
	default: // fetch_failed
		slog.Warn("gitops: fetch failed", "workload", row.ID, "detail", res.Message)
		respondError(w, http.StatusBadGateway, "could not fetch "+ref.Path+" from the repo")
		return
	}

	src, err := plugin.GetSource(row.SourceKind)
	if err != nil {
		respondError(w, http.StatusInternalServerError, "unknown source kind")
		return
	}
	plan := gitops.BuildPlan(res.Spec, row.SourceKind)
	merged, err := gitops.MergeAndValidate(json.RawMessage(row.SourceConfig), plan, src.Validate)
	if err != nil {
		// The merged config failed the source's own Validate — the file
		// declares something this workload can't accept. Safe to surface (it
		// describes config shape, not secrets).
		respondError(w, http.StatusBadRequest, "the repo config was rejected: "+err.Error())
		return
	}

	// Persist via a full-row update off the row we loaded (single read →
	// merge → write). A per-workload sync lock that closes the remaining
	// edit-vs-sync window is a Phase 4 hardening item.
	row.SourceConfig = string(merged)
	if err := s.store.UpdateWorkload(row); err != nil {
		slog.Error("gitops: persist merged config", "workload", row.ID, "error", err)
		respondError(w, http.StatusInternalServerError, "failed to apply the repo config")
		return
	}
	if err := s.store.RecordGitOpsSync(row.ID, res.CommitSHA, store.Now()); err != nil {
		slog.Warn("gitops: record sync", "workload", row.ID, "error", err)
	}

	actor := "manual"
	if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
		actor = claims.Username
	}
	appliedFields := planFields(plan)
	s.recordGitOpsEvent(row.ID, res.CommitSHA, actor, appliedFields)

	respondJSON(w, http.StatusOK, map[string]any{
		"status":         "applied",
		"commit_sha":     res.CommitSHA,
		"applied_fields": appliedFields,
		"triggered_by":   actor,
	})
}

// loadWorkload fetches a workload by id, writing the appropriate error response
// and returning ok=false on miss. Shared by the GitOps handlers.
func (s *Server) loadWorkload(w http.ResponseWriter, id string) (store.Workload, bool) {
	if id == "" {
		respondError(w, http.StatusBadRequest, "workload id is required")
		return store.Workload{}, false
	}
	row, err := s.store.GetWorkloadByID(id)
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			respondNotFound(w, "workload")
			return store.Workload{}, false
		}
		respondError(w, http.StatusInternalServerError, "get workload")
		return store.Workload{}, false
	}
	return row, true
}

// gitOpsRepoRef builds a gitops.RepoRef from a workload's source_config: it
// decodes the common git coords (identical keys across dockerfile + static)
// and decrypts the access token. The gitops package stays decoupled from the
// store/crypto by taking the plain coords.
func (s *Server) gitOpsRepoRef(row store.Workload) (gitops.RepoRef, error) {
	var c struct {
		Provider    string `json:"provider"`
		BaseURL     string `json:"base_url"`
		RepoOwner   string `json:"repo_owner"`
		RepoName    string `json:"repo_name"`
		Branch      string `json:"branch"`
		AccessToken string `json:"access_token"`
	}
	if err := json.Unmarshal([]byte(row.SourceConfig), &c); err != nil {
		return gitops.RepoRef{}, fmt.Errorf("decode source_config: %w", err)
	}
	token := ""
	if c.AccessToken != "" {
		dec, err := crypto.Decrypt(s.encKey, c.AccessToken)
		if err != nil {
			return gitops.RepoRef{}, fmt.Errorf("decrypt access token: %w", err)
		}
		token = dec
	}
	branch := c.Branch
	if branch == "" {
		branch = "main"
	}
	path := row.GitOpsPath
	if path == "" {
		path = ".tinyforge.yml"
	}
	return gitops.RepoRef{
		Provider: c.Provider,
		BaseURL:  c.BaseURL,
		Owner:    c.RepoOwner,
		Repo:     c.RepoName,
		Branch:   branch,
		Token:    token,
		Path:     path,
	}, nil
}

// recordGitOpsEvent writes a sync to the per-workload event log — the audit
// trail for a config-only sync, kept OUT of deploy_history (which the rollback
// feature treats as redeployable rows).
func (s *Server) recordGitOpsEvent(workloadID, sha, actor string, fields []string) {
	meta, _ := json.Marshal(map[string]any{"commit_sha": sha, "by": actor, "fields": fields})
	if _, err := s.store.InsertEvent(store.EventLog{
		Source:     "gitops",
		WorkloadID: workloadID,
		Severity:   "info",
		Message:    "GitOps config synced from repo",
		Metadata:   string(meta),
	}); err != nil {
		slog.Warn("gitops: record event", "workload", workloadID, "error", err)
	}
}

// validGitOpsPath rejects absolute paths, traversal, and URL-significant or
// control characters so a stored config path can't escape the repo (review M2)
// or smuggle a query/fragment onto the provider's raw-file URL (review LOW-1).
func validGitOpsPath(p string) bool {
	if p == "" || len(p) > 255 {
		return false
	}
	if strings.HasPrefix(p, "/") || strings.HasPrefix(p, "\\") {
		return false
	}
	if strings.Contains(p, "..") {
		return false
	}
	for _, r := range p {
		if r < 0x20 || r == 0x7f || r == '?' || r == '#' || r == ' ' || r == '\\' {
			return false
		}
	}
	return true
}

// planFields returns the source_config keys an apply plan touches.
func planFields(plan gitops.ApplyPlan) []string {
	fields := make([]string, 0, len(plan.SourceConfigPatch))
	for k := range plan.SourceConfigPatch {
		fields = append(fields, k)
	}
	return fields
}
