package stub

import (
	"bufio"
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// Generate is the main entry point for stub generation.
// It determines the stub kind, checks cache, generates source code,
// and writes everything to the output directory.
func Generate(cfg *Config) (*Result, error) {
	if cfg.Service == "" {
		return nil, fmt.Errorf("service name is required")
	}
	if cfg.OutputDir == "" {
		cfg.OutputDir = filepath.Join(".cp9", "stubs", cfg.Service)
	}

	// Determine stub kind if not set.
	if cfg.Kind == "" {
		cfg.Kind = detectKind(cfg)
	}

	// Compute cache key from input files.
	cacheKey, inputFiles := computeInputCacheKey(cfg)

	// Check cache (unless forced).
	if !cfg.Force && cacheKey != "" && CheckCache(cfg.OutputDir, cacheKey) {
		return &Result{
			Service:   cfg.Service,
			Kind:      cfg.Kind,
			OutputDir: cfg.OutputDir,
			CacheKey:  cacheKey,
			Cached:    true,
		}, nil
	}

	// Generate the stub source files.
	files, err := generateFiles(cfg)
	if err != nil {
		return nil, fmt.Errorf("generate %s stub for %s: %w", cfg.Kind, cfg.Service, err)
	}

	// Read Go version from root go.mod for consistent toolchain alignment.
	goVer := goVersionFromMod()

	// Generate Dockerfile.
	dockerfile, err := GenerateDockerfile(cfg, goVer)
	if err != nil {
		return nil, fmt.Errorf("generate dockerfile for %s: %w", cfg.Service, err)
	}
	files["Dockerfile"] = dockerfile

	// Generate go.mod for the stub.
	files["go.mod"] = generateGoMod(cfg, goVer)

	// Write all files to the output directory.
	if err := writeFiles(cfg.OutputDir, files); err != nil {
		return nil, fmt.Errorf("write stub files for %s: %w", cfg.Service, err)
	}

	// Copy input files for reproducibility.
	if err := copyInputFiles(cfg.OutputDir, inputFiles); err != nil {
		return nil, fmt.Errorf("copy input files for %s: %w", cfg.Service, err)
	}

	// Write generation manifest for drift detection.
	if err := writeManifest(cfg.OutputDir, cfg.Kind, inputFiles); err != nil {
		return nil, fmt.Errorf("write manifest for %s: %w", cfg.Service, err)
	}

	// Write cache key.
	if cacheKey != "" {
		if err := WriteCacheKey(cfg.OutputDir, cacheKey); err != nil {
			return nil, fmt.Errorf("write cache key for %s: %w", cfg.Service, err)
		}
	}

	return &Result{
		Service:   cfg.Service,
		Kind:      cfg.Kind,
		OutputDir: cfg.OutputDir,
		CacheKey:  cacheKey,
		Cached:    false,
	}, nil
}

func detectKind(cfg *Config) Kind {
	if len(cfg.ProtoFiles) > 0 {
		return KindProto
	}
	if cfg.SwaggerFile != "" {
		return KindSwagger
	}
	return KindPermissive
}

func computeInputCacheKey(cfg *Config) (string, []string) {
	var inputFiles []string
	switch cfg.Kind {
	case KindProto, KindAccountLocal:
		inputFiles = cfg.ProtoFiles
	case KindSwagger:
		if cfg.SwaggerFile != "" {
			inputFiles = []string{cfg.SwaggerFile}
		}
	case KindPermissive, KindDataplaneManager, KindMeteringRecorder, KindBillingRecorder, KindRegionalAPIProxy:
		// No input files — permissive stubs are always the same.
		return "", nil
	}

	if len(inputFiles) == 0 {
		return "", nil
	}

	key, err := ComputeCacheKey(inputFiles)
	if err != nil {
		return "", inputFiles
	}
	return key, inputFiles
}

func generateFiles(cfg *Config) (map[string]string, error) {
	switch cfg.Kind {
	case KindProto:
		defs, err := ParseProtoFiles(cfg.ProtoFiles)
		if err != nil {
			return nil, err
		}
		return GenerateProtoStub(cfg, defs)
	case KindSwagger:
		routes, err := ParseSwaggerFile(cfg.SwaggerFile)
		if err != nil {
			return nil, err
		}
		return GenerateSwaggerStub(cfg, routes)
	case KindPermissive:
		return GeneratePermissiveStub(cfg)
	case KindAccountLocal:
		return GenerateAccountLocalStub(cfg)
	case KindDataplaneManager:
		return GenerateDataplaneManagerLocalStub(cfg)
	case KindRegionalAPIProxy:
		return GenerateRegionalAPIProxyStub(cfg)
	case KindMeteringRecorder, KindBillingRecorder:
		return GenerateRecordingStub(cfg)
	default:
		return nil, fmt.Errorf("unknown stub kind: %s", cfg.Kind)
	}
}

// goVersionFromMod reads the "go X.Y.Z" directive from the nearest go.mod.
// It walks up from the current directory to find go.mod.
// Falls back to "1.22" if no go.mod is found.
func goVersionFromMod() string {
	dir, err := os.Getwd()
	if err != nil {
		return "1.22"
	}

	for {
		modPath := filepath.Join(dir, "go.mod")
		if ver := readGoVersion(modPath); ver != "" {
			return ver
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			break
		}
		dir = parent
	}
	return "1.22"
}

func readGoVersion(modPath string) string {
	f, err := os.Open(modPath)
	if err != nil {
		return ""
	}
	defer f.Close()

	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if after, ok := strings.CutPrefix(line, "go "); ok {
			return after
		}
	}
	return ""
}

func generateGoMod(cfg *Config, goVer string) string {
	mod := fmt.Sprintf("module stub-%s\n\ngo %s\n", cfg.Service, goVer)
	if cfg.Kind == KindProto ||
		cfg.Kind == KindPermissive ||
		cfg.Kind == KindAccountLocal ||
		cfg.Kind == KindDataplaneManager ||
		IsRecordingKind(cfg.Kind) {
		mod += "\nrequire (\n\tgoogle.golang.org/grpc v1.65.0\n\tgoogle.golang.org/protobuf v1.34.2\n)\n"
	}
	return mod
}

func writeFiles(dir string, files map[string]string) error {
	if err := os.MkdirAll(dir, 0o755); err != nil {
		return fmt.Errorf("mkdir %s: %w", dir, err)
	}
	for name, content := range files {
		path := filepath.Join(dir, name)
		if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
			return fmt.Errorf("write %s: %w", path, err)
		}
	}
	return nil
}

// copyInputFiles copies input definition files into the output directory,
// preserving relative directory structure to avoid basename collisions
// (e.g. multiple "common.proto" from different directories).
func copyInputFiles(outputDir string, inputFiles []string) error {
	if len(inputFiles) == 0 {
		return nil
	}
	defDir := filepath.Join(outputDir, "definitions")

	// Find common prefix to compute relative paths.
	prefix := commonDirPrefix(inputFiles)

	for _, f := range inputFiles {
		data, err := os.ReadFile(f)
		if err != nil {
			continue // best-effort copy
		}

		// Compute relative path from common prefix.
		rel, err := filepath.Rel(prefix, f)
		if err != nil {
			rel = filepath.Base(f)
		}

		dst := filepath.Join(defDir, rel)
		// Ensure destination stays within defDir (prevent path traversal).
		if !strings.HasPrefix(filepath.Clean(dst), filepath.Clean(defDir)) {
			continue
		}
		if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
			return err
		}
		if err := os.WriteFile(dst, data, 0o600); err != nil { //nolint:gosec // path validated with HasPrefix above
			return err
		}
	}
	return nil
}

// Manifest records how a stub was generated so drift detection can use the
// same input file paths and kind.
type Manifest struct {
	Kind       Kind     `json:"kind"`
	InputFiles []string `json:"input_files"`
}

const manifestFile = "manifest.json"

func writeManifest(outputDir string, kind Kind, inputFiles []string) error {
	m := Manifest{Kind: kind, InputFiles: inputFiles}
	data, err := json.MarshalIndent(m, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(filepath.Join(outputDir, manifestFile), data, 0o600)
}

// ErrNoManifest is returned when no generation manifest exists in a stub output directory.
var ErrNoManifest = fmt.Errorf("no generation manifest found")

// ReadManifest reads the generation manifest from a stub output directory.
// Returns ErrNoManifest if no manifest file exists.
func ReadManifest(outputDir string) (*Manifest, error) {
	data, err := os.ReadFile(filepath.Join(outputDir, manifestFile))
	if err != nil {
		if os.IsNotExist(err) {
			return nil, ErrNoManifest
		}
		return nil, err
	}
	var m Manifest
	if err := json.Unmarshal(data, &m); err != nil {
		return nil, err
	}
	return &m, nil
}

// commonDirPrefix returns the longest common directory prefix of the given paths.
func commonDirPrefix(paths []string) string {
	if len(paths) == 0 {
		return ""
	}
	if len(paths) == 1 {
		return filepath.Dir(paths[0])
	}

	prefix := filepath.Dir(paths[0])
	for _, p := range paths[1:] {
		dir := filepath.Dir(p)
		for !strings.HasPrefix(dir, prefix) && prefix != "." && prefix != "/" {
			prefix = filepath.Dir(prefix)
		}
	}
	return prefix
}
