package infra

import (
	"fmt"
	"os"
	"path/filepath"
	"regexp"
	"sort"
	"strings"

	"gopkg.in/yaml.v3"
)

// ModuleDriftCheck describes per-app drift between app.md uses_modules and
// CMakeLists.txt fn_module_* link calls.
type ModuleDriftCheck struct {
	AppID        string   `json:"app_id"`
	AppMD        string   `json:"app_md"`
	CMakeLists   string   `json:"cmake_lists"`
	Declared     []string `json:"declared"`      // module IDs from uses_modules
	Linked       []string `json:"linked"`        // module names from fn_module_<name>
	MissingLinks []string `json:"missing_links"` // declared but not linked
	ExtraLinks   []string `json:"extra_links"`   // linked but not declared
	OK           bool     `json:"ok"`
}

var (
	cmakeLinkRE = regexp.MustCompile(`\bfn_module_([a-z0-9_]+)\b`)
)

// AuditModulesDrift scans apps/*/app.md, projects/*/apps/*/app.md, cpp/apps/*/app.md
// and compares uses_modules in the frontmatter against fn_module_<name> link calls
// in the adjacent CMakeLists.txt.
//
// An app is OK when:
//   - It has no CMakeLists.txt (non-C++ app) — drift check N/A; skipped silently.
//   - declared (modulo `_<lang>` suffix) == linked.
func AuditModulesDrift(root string) ([]ModuleDriftCheck, error) {
	candidates, err := findAppDirs(root)
	if err != nil {
		return nil, err
	}

	var result []ModuleDriftCheck
	for _, dir := range candidates {
		appMD := filepath.Join(dir, "app.md")
		cmakeLists := filepath.Join(dir, "CMakeLists.txt")

		if _, err := os.Stat(cmakeLists); err != nil {
			// Non-C++ app or app without CMakeLists. Skip drift check.
			continue
		}

		declared, appID, err := readUsesModules(appMD)
		if err != nil {
			continue
		}

		linked, err := readLinkedModules(cmakeLists)
		if err != nil {
			continue
		}

		// Normalize declared (module IDs like "data_table_cpp") to module names
		// for comparison with link target names ("fn_module_data_table" -> "data_table").
		declaredNames := make([]string, 0, len(declared))
		for _, d := range declared {
			declaredNames = append(declaredNames, stripLangSuffix(d))
		}

		missing := diffStrings(declaredNames, linked)
		extra := diffStrings(linked, declaredNames)

		relMD, _ := filepath.Rel(root, appMD)
		relCM, _ := filepath.Rel(root, cmakeLists)

		result = append(result, ModuleDriftCheck{
			AppID:        appID,
			AppMD:        relMD,
			CMakeLists:   relCM,
			Declared:     declaredNames,
			Linked:       linked,
			MissingLinks: missing,
			ExtraLinks:   extra,
			OK:           len(missing) == 0 && len(extra) == 0,
		})
	}

	sort.Slice(result, func(i, j int) bool { return result[i].AppID < result[j].AppID })
	return result, nil
}

// findAppDirs returns directories that contain an app.md file:
//   - <root>/apps/*/
//   - <root>/projects/*/apps/*/
func findAppDirs(root string) ([]string, error) {
	var dirs []string

	// <root>/apps/*/
	appsRoot := filepath.Join(root, "apps")
	if entries, err := os.ReadDir(appsRoot); err == nil {
		for _, e := range entries {
			if !e.IsDir() {
				continue
			}
			candidate := filepath.Join(appsRoot, e.Name())
			if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
				dirs = append(dirs, candidate)
			}
		}
	}

	// <root>/projects/*/apps/*/
	projectsRoot := filepath.Join(root, "projects")
	if projEntries, err := os.ReadDir(projectsRoot); err == nil {
		for _, pe := range projEntries {
			if !pe.IsDir() {
				continue
			}
			projAppsDir := filepath.Join(projectsRoot, pe.Name(), "apps")
			if appEntries, err := os.ReadDir(projAppsDir); err == nil {
				for _, ae := range appEntries {
					if !ae.IsDir() {
						continue
					}
					candidate := filepath.Join(projAppsDir, ae.Name())
					if _, err := os.Stat(filepath.Join(candidate, "app.md")); err == nil {
						dirs = append(dirs, candidate)
					}
				}
			}
		}
	}

	return dirs, nil
}

type appFrontmatter struct {
	Name        string   `yaml:"name"`
	Lang        string   `yaml:"lang"`
	Domain      string   `yaml:"domain"`
	UsesModules []string `yaml:"uses_modules"`
}

func readUsesModules(path string) (modules []string, appID string, err error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, "", err
	}
	// Extract YAML frontmatter between leading "---" markers.
	if !strings.HasPrefix(string(data), "---") {
		return nil, "", fmt.Errorf("missing frontmatter in %s", path)
	}
	rest := string(data)[4:]
	end := strings.Index(rest, "\n---")
	if end < 0 {
		return nil, "", fmt.Errorf("missing closing --- in %s", path)
	}
	fm := rest[:end]

	var raw appFrontmatter
	if err := yaml.Unmarshal([]byte(fm), &raw); err != nil {
		return nil, "", err
	}

	if raw.Name == "" {
		return nil, "", fmt.Errorf("no name in %s", path)
	}
	id := raw.Name
	if raw.Lang != "" {
		id += "_" + raw.Lang
	}
	if raw.Domain != "" {
		id += "_" + raw.Domain
	}
	return raw.UsesModules, id, nil
}

func readLinkedModules(cmakePath string) ([]string, error) {
	data, err := os.ReadFile(cmakePath)
	if err != nil {
		return nil, err
	}
	matches := cmakeLinkRE.FindAllStringSubmatch(string(data), -1)
	seen := map[string]bool{}
	var out []string
	for _, m := range matches {
		if len(m) < 2 {
			continue
		}
		if !seen[m[1]] {
			seen[m[1]] = true
			out = append(out, m[1])
		}
	}
	sort.Strings(out)
	return out, nil
}

// stripLangSuffix removes a trailing _<lang> suffix for matching purposes.
// "data_table_cpp" -> "data_table".
func stripLangSuffix(id string) string {
	for _, suf := range []string{"_cpp", "_py", "_ts", "_bash", "_go"} {
		if strings.HasSuffix(id, suf) {
			return id[:len(id)-len(suf)]
		}
	}
	return id
}

// diffStrings returns elements in a that are not in b.
func diffStrings(a, b []string) []string {
	bset := map[string]bool{}
	for _, x := range b {
		bset[x] = true
	}
	var out []string
	for _, x := range a {
		if !bset[x] {
			out = append(out, x)
		}
	}
	sort.Strings(out)
	return out
}
