package dockerfile

import (
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"sync"

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

// runtimeState is the per-workload state we persist inside the
// container row's extra_json blob. Mirrors the static plugin's
// runtimeState shape so anyone reading the DB can interpret the two
// kinds identically.
//
// LastImageDigest is the build's image ID — distinct from a registry
// digest (we never push) but useful for "did the build actually
// produce a different artifact?" diffing when we add caching later.
type runtimeState struct {
	LastCommitSHA   string `json:"last_commit_sha,omitempty"`
	LastImageDigest string `json:"last_image_digest,omitempty"`
	LastSyncAt      string `json:"last_sync_at,omitempty"`
	LastError       string `json:"last_error,omitempty"`
	Status          string `json:"status,omitempty"`
}

// runtimeStateKeys lists every JSON field name owned by runtimeState.
// saveState strips these from the generic map before re-emitting so
// the typed values do not double-write under both their JSON tag and
// any subsequent extension's tag.
var runtimeStateKeys = []string{
	"last_commit_sha", "last_image_digest", "last_sync_at", "last_error", "status",
}

// containerRowID is the deterministic container row ID. Stable across
// redeploys so saveState upserts in place.
func containerRowID(w plugin.Workload) string {
	return w.ID + ":dockerfile"
}

// loadState returns the persisted runtime state plus the underlying
// container row. Both values are zero on first deploy.
func loadState(deps plugin.Deps, w plugin.Workload) (runtimeState, *store.Container, error) {
	row, err := deps.Store.GetContainerByID(containerRowID(w))
	if err != nil {
		if errors.Is(err, store.ErrNotFound) {
			return runtimeState{}, nil, nil
		}
		return runtimeState{}, nil, fmt.Errorf("dockerfile source: load state: %w", err)
	}
	st := runtimeState{}
	if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
		if err := json.Unmarshal([]byte(row.ExtraJSON), &st); err != nil {
			slog.Debug("dockerfile source: decode extra_json", "workload", w.ID, "error", err)
		}
	}
	return st, &row, nil
}

// saveLocks serializes per-workload RMW of the container row. Same
// pattern as the static plugin — SQLite's MaxOpenConns=1 serializes
// statements but not the caller's read-then-write intent, so two
// concurrent deploys for the same workload could stomp each other's
// container_id / proxy_route_id without this mutex.
//
// Entries are reference-counted and removed only when the last holder
// releases. This bounds memory (no per-workload-ID leak) WITHOUT the
// use-after-delete hazard of deleting an entry on teardown: deleting a
// live entry while a concurrent saveState still holds (or is about to
// lock) it would let a fresh saveState mint a SECOND mutex for the same
// workload, losing the RMW serialization the lock exists to provide.
var saveLocks struct {
	mu    sync.Mutex
	locks map[string]*saveLock
}

type saveLock struct {
	mu   sync.Mutex
	refs int
}

// acquireSaveLock returns the per-workload lock (creating it on first use),
// registers this caller as a holder, and takes the lock. Pair with
// releaseSaveLock. The outer mutex is held only for the bookkeeping; callers
// contend on the returned per-workload lock.
func acquireSaveLock(workloadID string) *saveLock {
	saveLocks.mu.Lock()
	if saveLocks.locks == nil {
		saveLocks.locks = map[string]*saveLock{}
	}
	l, ok := saveLocks.locks[workloadID]
	if !ok {
		l = &saveLock{}
		saveLocks.locks[workloadID] = l
	}
	l.refs++
	saveLocks.mu.Unlock()
	l.mu.Lock()
	return l
}

// releaseSaveLock unlocks and drops the caller's reference, removing the map
// entry once no holders remain. Because refs is incremented under saveLocks.mu
// before the entry can be observed for deletion, an entry with a pending
// acquirer is never deleted.
func releaseSaveLock(workloadID string, l *saveLock) {
	l.mu.Unlock()
	saveLocks.mu.Lock()
	l.refs--
	if l.refs == 0 {
		delete(saveLocks.locks, workloadID)
	}
	saveLocks.mu.Unlock()
}

// saveState upserts the container row, calling mutate so callers can
// adjust both the typed runtime state and the row's first-class fields
// in one transaction. Unknown keys in extra_json survive the round-trip
// so future writers can extend the blob without forcing this struct to
// grow.
func saveState(deps plugin.Deps, w plugin.Workload, mutate func(*runtimeState, *store.Container)) error {
	lk := acquireSaveLock(w.ID)
	defer releaseSaveLock(w.ID, lk)

	prev, prevRow, err := loadState(deps, w)
	if err != nil {
		return err
	}

	row := store.Container{
		ID:           containerRowID(w),
		WorkloadID:   w.ID,
		WorkloadKind: string(store.WorkloadKindBuild),
		Host:         "local",
	}
	if prevRow != nil {
		row = *prevRow
	}

	generic := map[string]json.RawMessage{}
	if row.ExtraJSON != "" && row.ExtraJSON != "{}" {
		if err := json.Unmarshal([]byte(row.ExtraJSON), &generic); err != nil {
			slog.Debug("dockerfile source: decode extra_json (generic)", "workload", w.ID, "error", err)
		}
	}
	for _, k := range runtimeStateKeys {
		delete(generic, k)
	}

	state := prev
	mutate(&state, &row)

	typedBytes, err := json.Marshal(state)
	if err != nil {
		return fmt.Errorf("dockerfile source: marshal state: %w", err)
	}
	typedMap := map[string]json.RawMessage{}
	if err := json.Unmarshal(typedBytes, &typedMap); err != nil {
		return fmt.Errorf("dockerfile source: re-decode typed state: %w", err)
	}
	for k, v := range typedMap {
		generic[k] = v
	}

	merged, err := json.Marshal(generic)
	if err != nil {
		return fmt.Errorf("dockerfile source: marshal merged state: %w", err)
	}
	row.ExtraJSON = string(merged)
	row.LastSeenAt = store.Now()

	if err := deps.Store.UpsertContainer(row); err != nil {
		return fmt.Errorf("dockerfile source: upsert container row: %w", err)
	}
	return nil
}
