package project

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"slices"
	"strings"
)

const ConfigFile = "nwn-tool.json"

var SourceExtensions = []string{
	".are", ".dlg", ".fac", ".git", ".ifo", ".jrl",
	".utc", ".utd", ".ute", ".uti", ".utm", ".utp", ".uts", ".utt", ".utw",
}

var AssetExtensions = []string{
	".2da", ".bik", ".bmp", ".dds", ".dwk", ".gr2", ".itp", ".jpg", ".lod", ".lyt", ".mdb", ".mdl", ".mdx", ".mtr", ".plt", ".png", ".pwk", ".set", ".shd", ".tga", ".txi", ".uti", ".vis", ".wav", ".wlk", ".wok", ".xml",
}

type Project struct {
	Root      string
	Config    Config
	Inventory Inventory
}

type Config struct {
	Module  ModuleConfig  `json:"module"`
	Paths   PathConfig    `json:"paths"`
	HAKs    []HAKConfig   `json:"haks"`
	TopData TopDataConfig `json:"topdata"`
}

type ModuleConfig struct {
	Name        string   `json:"name"`
	ResRef      string   `json:"resref"`
	Description string   `json:"description"`
	HAKOrder    []string `json:"hak_order"`
}

type PathConfig struct {
	Source string `json:"source"`
	Assets string `json:"assets"`
	Build  string `json:"build"`
}

type HAKConfig struct {
	Name     string   `json:"name"`
	Priority int      `json:"priority"`
	MaxBytes int64    `json:"max_bytes"`
	Split    bool     `json:"split"`
	Optional bool     `json:"optional,omitempty"`
	Include  []string `json:"include"`
}

type TopDataConfig struct {
	Source           string `json:"source"`
	Build            string `json:"build"`
	ReferenceBuilder string `json:"reference_builder"`
	Assets           string `json:"assets"`
}

type Inventory struct {
	SourceFiles []string
	ScriptFiles []string
	AssetFiles  []string
	Extensions  []string
}

type InventoryReport struct {
	SourceFiles int
	ScriptFiles int
	AssetFiles  int
	Extensions  []string
}

func FindRoot(start string) (string, error) {
	current := start
	for {
		candidate := filepath.Join(current, ConfigFile)
		if _, err := os.Stat(candidate); err == nil {
			return current, nil
		}
		parent := filepath.Dir(current)
		if parent == current {
			return "", fmt.Errorf("could not find %s from %s", ConfigFile, start)
		}
		current = parent
	}
}

func Load(root string) (*Project, error) {
	raw, err := os.ReadFile(filepath.Join(root, ConfigFile))
	if err != nil {
		return nil, fmt.Errorf("read %s: %w", ConfigFile, err)
	}

	cfg := defaultConfig()
	if err := json.Unmarshal(raw, &cfg); err != nil {
		return nil, fmt.Errorf("parse %s: %w", ConfigFile, err)
	}

	return &Project{
		Root:   root,
		Config: cfg,
	}, nil
}

func (p *Project) ValidateLayout() error {
	var failures []error

	if strings.TrimSpace(p.Config.Module.Name) == "" {
		failures = append(failures, errors.New("module.name is required"))
	}
	if strings.TrimSpace(p.Config.Module.ResRef) == "" {
		failures = append(failures, errors.New("module.resref is required"))
	}
	if len(p.Config.Module.ResRef) > 16 {
		failures = append(failures, fmt.Errorf("module.resref %q exceeds 16 characters", p.Config.Module.ResRef))
	}

	for index, hak := range p.Config.HAKs {
		if strings.TrimSpace(hak.Name) == "" {
			failures = append(failures, fmt.Errorf("haks[%d].name is required", index))
		}
		if hak.Priority <= 0 {
			failures = append(failures, fmt.Errorf("haks[%d].priority must be greater than zero", index))
		}
		if hak.MaxBytes < 0 {
			failures = append(failures, fmt.Errorf("haks[%d].max_bytes must be zero or greater", index))
		}
		if len(hak.Include) == 0 {
			failures = append(failures, fmt.Errorf("haks[%d].include must have at least one pattern", index))
		}
	}

	for label, pathFn := range map[string]func() string{
		"paths.build": p.BuildDir,
	} {
		path := pathFn()
		if path == "" || filepath.Clean(path) == p.Root {
			continue
		}
		info, err := os.Stat(path)
		if err != nil {
			failures = append(failures, fmt.Errorf("%s: %w", label, err))
			continue
		}
		if !info.IsDir() {
			failures = append(failures, fmt.Errorf("%s must be a directory: %s", label, path))
		}
	}

	if len(failures) > 0 {
		return errors.Join(failures...)
	}

	return nil
}

func (p *Project) Scan() error {
	var sourceFiles []string
	var sourceExts []string

	sourceDir := p.SourceDir()
	if sourceDir != "" && filepath.Clean(sourceDir) != p.Root {
		var err error
		sourceFiles, sourceExts, err = scanDir(sourceDir, func(path string) bool {
			ext := strings.ToLower(filepath.Ext(path))
			return ext == ".json" || ext == ".nss"
		})
		if err != nil {
			if !errors.Is(err, os.ErrNotExist) {
				return fmt.Errorf("scan source tree: %w", err)
			}
		}
	}

	assetFiles, _, err := scanDir(p.AssetsDir(), func(path string) bool {
		ext := strings.ToLower(filepath.Ext(path))
		return slices.Contains(AssetExtensions, ext)
	})
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			assetFiles = nil
		} else {
			return fmt.Errorf("scan assets tree: %w", err)
		}
	}

	var scripts []string
	var sources []string
	extensionSet := map[string]struct{}{}
	for _, ext := range sourceExts {
		extensionSet[ext] = struct{}{}
	}

	for _, path := range sourceFiles {
		switch strings.ToLower(filepath.Ext(path)) {
		case ".nss":
			scripts = append(scripts, path)
		case ".json":
			sources = append(sources, path)
		}
	}

	extensions := make([]string, 0, len(extensionSet))
	for ext := range extensionSet {
		extensions = append(extensions, ext)
	}
	slices.Sort(extensions)

	p.Inventory = Inventory{
		SourceFiles: sources,
		ScriptFiles: scripts,
		AssetFiles:  assetFiles,
		Extensions:  extensions,
	}
	return nil
}

func (p *Project) SourceDir() string {
	return filepath.Join(p.Root, p.Config.Paths.Source)
}

func (p *Project) AssetsDir() string {
	return filepath.Join(p.Root, p.Config.Paths.Assets)
}

func (p *Project) BuildDir() string {
	return filepath.Join(p.Root, p.Config.Paths.Build)
}

func (p *Project) CloneWithHAKNames(names []string) (*Project, error) {
	if len(names) == 0 {
		return p, nil
	}

	wanted := make(map[string]struct{}, len(names))
	for _, name := range names {
		trimmed := strings.TrimSpace(strings.ToLower(name))
		if trimmed == "" {
			continue
		}
		wanted[trimmed] = struct{}{}
	}
	if len(wanted) == 0 {
		return p, nil
	}

	filtered := make([]HAKConfig, 0, len(p.Config.HAKs))
	found := make(map[string]struct{}, len(wanted))
	for _, hak := range p.Config.HAKs {
		key := strings.ToLower(strings.TrimSpace(hak.Name))
		if _, ok := wanted[key]; !ok {
			continue
		}
		filtered = append(filtered, hak)
		found[key] = struct{}{}
	}

	var missing []string
	for _, name := range names {
		key := strings.TrimSpace(strings.ToLower(name))
		if key == "" {
			continue
		}
		if _, ok := found[key]; !ok {
			missing = append(missing, name)
		}
	}
	if len(missing) > 0 {
		return nil, fmt.Errorf("unknown hak name(s): %s", strings.Join(missing, ", "))
	}

	clone := *p
	clone.Config = p.Config
	clone.Config.HAKs = filtered
	clone.Inventory = p.Inventory
	return &clone, nil
}

func (p *Project) HasTopData() bool {
	return strings.TrimSpace(p.Config.TopData.Source) != ""
}

func (p *Project) TopDataSourceDir() string {
	if !p.HasTopData() {
		return ""
	}
	return filepath.Join(p.Root, p.Config.TopData.Source)
}

func (p *Project) TopDataBuildDir() string {
	buildPath := strings.TrimSpace(p.Config.TopData.Build)
	if buildPath == "" {
		buildPath = filepath.Join(p.Config.Paths.Build, "topdata")
	}
	if filepath.IsAbs(buildPath) {
		return buildPath
	}
	return filepath.Join(p.Root, buildPath)
}

func (p *Project) TopDataReferenceBuilderDir() string {
	ref := strings.TrimSpace(p.Config.TopData.ReferenceBuilder)
	if ref == "" {
		return ""
	}
	if filepath.IsAbs(ref) {
		return ref
	}
	return filepath.Join(p.Root, ref)
}

func (p *Project) TopDataAssetsDir() string {
	assets := strings.TrimSpace(p.Config.TopData.Assets)
	if assets == "" {
		return ""
	}
	if filepath.IsAbs(assets) {
		return assets
	}
	absPath := filepath.Join(p.Root, assets)
	if _, err := os.Stat(absPath); err == nil {
		return absPath
	}
	return absPath
}

func (i Inventory) Report() InventoryReport {
	return InventoryReport{
		SourceFiles: len(i.SourceFiles),
		ScriptFiles: len(i.ScriptFiles),
		AssetFiles:  len(i.AssetFiles),
		Extensions:  i.Extensions,
	}
}

func defaultConfig() Config {
	return Config{
		Paths: PathConfig{
			Build: "build",
		},
		TopData: TopDataConfig{
			Build: ".cache",
		},
	}
}

func scanDir(root string, include func(path string) bool) ([]string, []string, error) {
	var files []string
	extSet := map[string]struct{}{}

	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if d.IsDir() {
			return nil
		}
		if !include(path) {
			return nil
		}

		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		rel = filepath.ToSlash(rel)
		files = append(files, rel)

		if strings.HasSuffix(rel, ".json") {
			base := strings.TrimSuffix(filepath.Base(rel), ".json")
			ext := strings.ToLower(filepath.Ext(base))
			if ext != "" {
				extSet[ext] = struct{}{}
			}
		}

		return nil
	})
	if err != nil {
		return nil, nil, err
	}

	slices.Sort(files)

	exts := make([]string, 0, len(extSet))
	for ext := range extSet {
		exts = append(exts, ext)
	}
	slices.Sort(exts)
	return files, exts, nil
}
