package tilt

import (
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"reflect"
	"slices"
	"strings"
)

const (
	RuntimePlanVersion = "cp9.tidbcloud.com/runtime-plan/v1"
	RuntimePrimary     = "primary"
	RuntimeGCPRegional = "gcp-regional"
	runtimePlanName    = "runtime-plan.json"
)

// RuntimePlan is the derived, session-owned routing receipt for evaluated
// Tilt resource graphs. It is never user-authored configuration.
type RuntimePlan struct {
	APIVersion     string              `json:"api_version"`
	SessionName    string              `json:"session_name"`
	TiltfilePath   string              `json:"tiltfile_path"`
	TiltfileSHA256 string              `json:"tiltfile_sha256"`
	Runtimes       []RuntimeMembership `json:"runtimes"`
}

type RuntimeMembership struct {
	ID        string   `json:"id"`
	StateDir  string   `json:"state_dir"`
	Resources []string `json:"resources"`
}

func RuntimePlanPath(stateDir string) string { return filepath.Join(stateDir, runtimePlanName) }

func GCPRuntimeAPIPort(primary int) (int, error) {
	if primary <= 0 {
		primary = DefaultAPIPort
	}
	if primary >= 65535 {
		return 0, fmt.Errorf("primary Tilt port %d leaves no distinct GCP runtime port", primary)
	}
	return primary + 1, nil
}

func BuildRuntimePlan(
	stateDir string,
	sessionName string,
	tiltfilePath string,
	runtimes ...RuntimeMembership,
) (*RuntimePlan, error) {
	data, err := os.ReadFile(tiltfilePath)
	if err != nil {
		return nil, fmt.Errorf("read Session Tiltfile: %w", err)
	}
	persistedTiltfilePath := SessionRelativePath(stateDir, tiltfilePath)
	if !sessionRelativeRuntimePath(persistedTiltfilePath) {
		persistedTiltfilePath, err = filepath.Abs(tiltfilePath)
		if err != nil {
			return nil, fmt.Errorf("resolve Session Tiltfile: %w", err)
		}
	}
	digest := sha256.Sum256(data)
	plan := &RuntimePlan{
		APIVersion:     RuntimePlanVersion,
		SessionName:    strings.TrimSpace(sessionName),
		TiltfilePath:   filepath.ToSlash(persistedTiltfilePath),
		TiltfileSHA256: hex.EncodeToString(digest[:]),
		Runtimes:       append([]RuntimeMembership(nil), runtimes...),
	}
	if err := plan.validate(); err != nil {
		return nil, err
	}
	return plan, nil
}

func Membership(stateDir, id, runtimeStateDir string, readiness *ResourceReadiness) (RuntimeMembership, error) {
	rel, err := filepath.Rel(stateDir, runtimeStateDir)
	if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
		return RuntimeMembership{}, fmt.Errorf("runtime %s state directory escapes the session", id)
	}
	resources := make([]string, 0)
	if readiness != nil {
		resources = make([]string, 0, len(readiness.ResourceStates))
		for name := range readiness.ResourceStates {
			resources = append(resources, name)
		}
		slices.Sort(resources)
	}
	return RuntimeMembership{
		ID:        strings.TrimSpace(id),
		StateDir:  filepath.ToSlash(rel),
		Resources: resources,
	}, nil
}

func SaveRuntimePlan(stateDir string, plan *RuntimePlan) error {
	if plan == nil {
		return fmt.Errorf("runtime plan is required")
	}
	if err := plan.validate(); err != nil {
		return err
	}
	path := RuntimePlanPath(stateDir)
	if existing, err := LoadRuntimePlan(stateDir); err == nil {
		if !reflect.DeepEqual(existing, plan) {
			return fmt.Errorf("evaluated runtime graph changed for the existing session; use a new session name")
		}
		return nil
	} else if !errors.Is(err, os.ErrNotExist) {
		return err
	}
	data, err := json.MarshalIndent(plan, "", "  ")
	if err != nil {
		return fmt.Errorf("marshal runtime plan: %w", err)
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return fmt.Errorf("create runtime plan directory: %w", err)
	}
	temporary, err := os.CreateTemp(filepath.Dir(path), "runtime-plan.*.tmp")
	if err != nil {
		return fmt.Errorf("create runtime plan temporary file: %w", err)
	}
	temporaryPath := temporary.Name()
	defer os.Remove(temporaryPath)
	if err := temporary.Chmod(0o600); err != nil {
		_ = temporary.Close()
		return fmt.Errorf("chmod runtime plan temporary file: %w", err)
	}
	if _, err := temporary.Write(data); err != nil {
		_ = temporary.Close()
		return fmt.Errorf("write runtime plan temporary file: %w", err)
	}
	if err := temporary.Close(); err != nil {
		return fmt.Errorf("close runtime plan temporary file: %w", err)
	}
	if err := os.Rename(temporaryPath, path); err != nil {
		return fmt.Errorf("publish runtime plan: %w", err)
	}
	return nil
}

func LoadRuntimePlan(stateDir string) (*RuntimePlan, error) {
	data, err := os.ReadFile(RuntimePlanPath(stateDir))
	if err != nil {
		return nil, err
	}
	var plan RuntimePlan
	if err := json.Unmarshal(data, &plan); err != nil {
		return nil, fmt.Errorf("parse runtime plan: %w", err)
	}
	if err := plan.validate(); err != nil {
		return nil, err
	}
	return &plan, nil
}

func ValidateRuntimePlanTiltfile(stateDir, tiltfilePath string) error {
	plan, err := LoadRuntimePlan(stateDir)
	if errors.Is(err, os.ErrNotExist) {
		return nil
	}
	if err != nil {
		return err
	}
	return plan.ValidateTiltfile(tiltfilePath)
}

// ValidateTiltfile rejects day-2 routing after the authored Session Tiltfile drifts.
func (p *RuntimePlan) ValidateTiltfile(tiltfilePath string) error {
	if p == nil {
		return fmt.Errorf("runtime plan is required")
	}
	data, err := os.ReadFile(tiltfilePath)
	if err != nil {
		return fmt.Errorf("read Session Tiltfile: %w", err)
	}
	digest := sha256.Sum256(data)
	if hex.EncodeToString(digest[:]) != p.TiltfileSHA256 {
		return fmt.Errorf("session Tiltfile changed for the existing runtime plan; use a new session name")
	}
	return nil
}

func (p *RuntimePlan) Resolve(resource, requestedRuntime string) (*RuntimeMembership, error) {
	if p == nil {
		return nil, fmt.Errorf("runtime plan is required")
	}
	resource = strings.TrimSpace(resource)
	requestedRuntime = strings.TrimSpace(requestedRuntime)
	var matches []*RuntimeMembership
	for i := range p.Runtimes {
		runtime := &p.Runtimes[i]
		if requestedRuntime != "" && runtime.ID != requestedRuntime {
			continue
		}
		if slices.Contains(runtime.Resources, resource) {
			matches = append(matches, runtime)
		}
	}
	if requestedRuntime != "" && len(matches) == 0 {
		return nil, fmt.Errorf("tilt resource %s is not present in runtime %s", resource, requestedRuntime)
	}
	switch len(matches) {
	case 1:
		return matches[0], nil
	case 0:
		return nil, fmt.Errorf("tilt resource %s is absent from runtimes %s", resource, strings.Join(p.runtimeIDs(), ", "))
	default:
		return nil, fmt.Errorf("tilt resource %s exists in runtimes %s; select one with runtime", resource, runtimeIDs(matches))
	}
}

func (p *RuntimePlan) validate() error {
	if p == nil || p.APIVersion != RuntimePlanVersion || strings.TrimSpace(p.SessionName) == "" ||
		(!filepath.IsAbs(filepath.FromSlash(p.TiltfilePath)) && !sessionRelativeRuntimePath(p.TiltfilePath)) ||
		len(p.TiltfileSHA256) != sha256.Size*2 || len(p.Runtimes) == 0 {
		return fmt.Errorf("runtime plan contract is incomplete")
	}
	seen := map[string]bool{}
	for i := range p.Runtimes {
		runtime := &p.Runtimes[i]
		if runtime.ID != RuntimePrimary && runtime.ID != RuntimeGCPRegional {
			return fmt.Errorf("runtime plan has unsupported runtime %q", runtime.ID)
		}
		if seen[runtime.ID] || !sessionRelativeRuntimePath(runtime.StateDir) {
			return fmt.Errorf("runtime plan has incomplete or duplicate runtime %q", runtime.ID)
		}
		seen[runtime.ID] = true
		if !slices.IsSorted(runtime.Resources) {
			return fmt.Errorf("runtime plan resources for %s are not sorted", runtime.ID)
		}
	}
	if len(seen) != 2 || !seen[RuntimePrimary] || !seen[RuntimeGCPRegional] {
		return fmt.Errorf("runtime plan must contain primary and gcp-regional")
	}
	return nil
}

func sessionRelativeRuntimePath(value string) bool {
	value = strings.TrimSpace(value)
	if value == "" {
		return false
	}
	clean := filepath.Clean(filepath.FromSlash(value))
	return !filepath.IsAbs(clean) && clean != ".." && !strings.HasPrefix(clean, ".."+string(filepath.Separator))
}

func (p *RuntimePlan) runtimeIDs() []string {
	ids := make([]string, 0, len(p.Runtimes))
	for i := range p.Runtimes {
		ids = append(ids, p.Runtimes[i].ID)
	}
	return ids
}

func runtimeIDs(runtimes []*RuntimeMembership) string {
	ids := make([]string, 0, len(runtimes))
	for _, runtime := range runtimes {
		ids = append(ids, runtime.ID)
	}
	return strings.Join(ids, ", ")
}
