package persona

import (
	"fmt"
	"maps"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"gopkg.in/yaml.v3"

	cperr "github.com/tidbcloud/controlplane9/internal/errors"
)

// DefaultTopologiesDir is the default location for persona templates relative
// to the cp9 repo root.
const DefaultTopologiesDir = "topologies"

// Load reads and parses topologies/<name>.yaml from the given directory.
// The persona's `persona:` field must match the filename stem; mismatch is a
// config-category error.
func Load(dir, name string) (*Persona, error) {
	if name == "" {
		return nil, cperr.New(cperr.CategoryConfig, "persona name is required")
	}
	path := filepath.Join(dir, name+".yaml")
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			avail, _ := List(dir)
			return nil, cperr.Newf(cperr.CategoryConfig,
				"persona %q not found at %s (available: %s)",
				name, path, strings.Join(avail, ", "))
		}
		return nil, cperr.Newf(cperr.CategoryConfig, "read persona %s: %v", path, err)
	}
	var p Persona
	if err := yaml.Unmarshal(data, &p); err != nil {
		return nil, cperr.Newf(cperr.CategoryConfig, "parse persona %s: %v", path, err)
	}
	if p.Persona == "" {
		p.Persona = name
	}
	if p.Persona != name {
		return nil, cperr.Newf(cperr.CategoryConfig,
			"persona file %s declares persona %q; filename stem %q must match",
			path, p.Persona, name)
	}
	return &p, nil
}

// List returns all persona names available in the directory, sorted.
func List(dir string) ([]string, error) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, cperr.Newf(cperr.CategoryConfig, "list personas in %s: %v", dir, err)
	}
	var names []string
	for _, e := range entries {
		if e.IsDir() {
			continue
		}
		name := e.Name()
		if !strings.HasSuffix(name, ".yaml") {
			continue
		}
		names = append(names, strings.TrimSuffix(name, ".yaml"))
	}
	slices.Sort(names)
	return names, nil
}

// BuildPlan merges a legacy persona/topology file with per-developer
// LocalOverrides into a Plan.
//
// The persona is validated through Validate first, so callers always get the
// same config-category error surface regardless of where invalid templates
// originate (CLI, tests, programmatic callers).
//
// Merge rules:
//   - Roles default to the persona's real/stub lists; force_real/force_stub
//     overrides win, and they cannot conflict for the same service.
//   - Sources for source services come from LocalOverrides.Sources. A source
//     service without a source override is a config error.
//   - ExtraEnv is per-service; overrides merge with no defaults from persona.
//   - Services are sorted alphabetically for stable output.
func BuildPlan(p *Persona, local *LocalOverrides) (*Plan, error) {
	if p == nil {
		return nil, cperr.New(cperr.CategoryConfig, "persona is required")
	}
	if err := Validate(p); err != nil {
		return nil, err
	}
	if local == nil {
		local = &LocalOverrides{}
	}

	// Conflict between force_real and force_stub on the same service is illegal.
	for _, svc := range local.ForceReal {
		if slices.Contains(local.ForceStub, svc) {
			return nil, cperr.Newf(cperr.CategoryConfig,
				"service %q listed in both overrides.force_real and overrides.force_stub", svc)
		}
	}

	roles := make(map[string]Role)
	for _, svc := range p.Real {
		roles[svc] = RoleReal
	}
	for _, svc := range p.Stub {
		roles[svc] = RoleStub
	}
	for _, svc := range local.ForceReal {
		roles[svc] = RoleReal
	}
	for _, svc := range local.ForceStub {
		roles[svc] = RoleStub
	}

	plan := &Plan{
		Persona:      p.Persona,
		Owners:       append([]string(nil), p.Owners...),
		Capabilities: append([]string(nil), p.Requires...),
		Clouds:       append([]string(nil), p.Clouds...),
	}

	names := make([]string, 0, len(roles))
	for name := range roles {
		names = append(names, name)
	}
	slices.Sort(names)

	for _, name := range names {
		role := roles[name]
		entry := ServicePlan{Name: name, Role: role}
		if role == RoleReal {
			src, ok := local.Sources[name]
			if !ok || src.Path == "" {
				return nil, cperr.Newf(cperr.CategoryConfig,
					"legacy persona %q service %q is source-built but has no source in cp9.local.yaml sources",
					p.Persona, name)
			}
			if !strings.HasSuffix(src.Path, ".go") {
				return nil, cperr.Newf(cperr.CategoryConfig,
					"persona %q service %q source.path %q must point at the main-package main.go file "+
						"(e.g. .../cmd/%s/main.go); a multi-module repo can host more than one main "+
						"package, so a directory is ambiguous",
					p.Persona, name, src.Path, name)
			}
			s := src
			entry.Source = &s
		}
		if env, ok := local.ExtraEnv[name]; ok && len(env) > 0 {
			cp := make(map[string]string, len(env))
			maps.Copy(cp, env)
			entry.ExtraEnv = cp
		}
		plan.Services = append(plan.Services, entry)
	}

	return plan, nil
}

// FormatText implements model.TextFormatter for human-readable output.
func (p *Persona) FormatText() string {
	var b strings.Builder
	fmt.Fprintf(&b, "persona: %s\n", p.Persona)
	if len(p.Owners) > 0 {
		fmt.Fprintf(&b, "owners: %s\n", strings.Join(p.Owners, ", "))
	}
	if len(p.Real) > 0 {
		fmt.Fprintf(&b, "real (%d): %s\n", len(p.Real), strings.Join(p.Real, ", "))
	}
	if len(p.Stub) > 0 {
		fmt.Fprintf(&b, "stub (%d): %s\n", len(p.Stub), strings.Join(p.Stub, ", "))
	}
	if len(p.Requires) > 0 {
		fmt.Fprintf(&b, "requires: %s\n", strings.Join(p.Requires, ", "))
	}
	if len(p.Clouds) > 0 {
		fmt.Fprintf(&b, "clouds: %s\n", strings.Join(p.Clouds, ", "))
	}
	return b.String()
}

// Catalog wraps a slice of persona names for ticat JSON/text output.
type Catalog struct {
	Personas []string `json:"personas"`
}

// FormatText implements model.TextFormatter.
func (c Catalog) FormatText() string {
	if len(c.Personas) == 0 {
		return "no personas found"
	}
	return strings.Join(c.Personas, "\n")
}

// FormatText implements model.TextFormatter for Plan.
func (p *Plan) FormatText() string {
	var b strings.Builder
	fmt.Fprintf(&b, "persona: %s\n", p.Persona)
	if len(p.Capabilities) > 0 {
		fmt.Fprintf(&b, "capabilities: %s\n", strings.Join(p.Capabilities, ", "))
	}
	if len(p.Clouds) > 0 {
		fmt.Fprintf(&b, "clouds: %s\n", strings.Join(p.Clouds, ", "))
	}
	var sources, lives, stubs []string
	for _, s := range p.Services {
		switch s.Role {
		case RoleSource:
			sources = append(sources, s.Name)
		case RoleLive:
			lives = append(lives, s.Name)
		case RoleStub:
			stubs = append(stubs, s.Name)
		}
	}
	if len(sources) > 0 {
		fmt.Fprintf(&b, "source (%d): %s\n", len(sources), strings.Join(sources, ", "))
	}
	if len(lives) > 0 {
		fmt.Fprintf(&b, "live (%d): %s\n", len(lives), strings.Join(lives, ", "))
	}
	if len(stubs) > 0 {
		fmt.Fprintf(&b, "stub (%d): %s\n", len(stubs), strings.Join(stubs, ", "))
	}
	return b.String()
}
