package gitops

import (
	"encoding/json"
	"fmt"
)

// MergeAndValidate overlays the plan's SourceConfigPatch onto a copy of the
// live source_config and returns the merged JSON — but only after the target
// source's own Validate accepts the *merged* result. This is the hard apply
// gate (review C4):
//
//   - omitted-field-preserving: keys the file doesn't declare are untouched, so
//     a partial .tinyforge.yml never clears live config;
//   - validate-then-commit: a patch that would produce an invalid config (e.g.
//     deploy_strategy "blue-green" on a source that rejects it, or a bad port)
//     is refused as a whole — the function never returns a partial/empty config;
//   - pure: it does not write anything; the caller persists the returned bytes.
//
// validate is the matching Source.Validate (passed in to keep this package
// decoupled from the source plugins).
func MergeAndValidate(live json.RawMessage, plan ApplyPlan, validate func(json.RawMessage) error) (json.RawMessage, error) {
	// Decode the live config into a generic map we can overlay. An empty/null
	// live config starts from an empty object rather than failing.
	merged := map[string]any{}
	if len(live) > 0 {
		if err := json.Unmarshal(live, &merged); err != nil {
			return nil, fmt.Errorf("gitops: decode live source_config: %w", err)
		}
	}

	// Overlay only the declared patch keys — everything else is preserved.
	for k, v := range plan.SourceConfigPatch {
		merged[k] = v
	}

	out, err := json.Marshal(merged)
	if err != nil {
		return nil, fmt.Errorf("gitops: encode merged source_config: %w", err)
	}

	if validate != nil {
		if err := validate(out); err != nil {
			return nil, fmt.Errorf("gitops: merged config rejected: %w", err)
		}
	}
	return out, nil
}
