package pipeline

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

	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/gff"
	"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)

type CompareResult struct {
	ModulePath string
	HAKPaths   []string
	Checked    int
}

type resourceExpectation struct {
	Key   string
	Kind  string
	Bytes []byte
}

func Compare(p *project.Project) (CompareResult, error) {
	modulePath := p.ModuleArchivePath()

	var hakPaths []string
	if len(p.Inventory.AssetFiles) > 0 {
		var err error
		hakPaths, err = manifestHAKPaths(p)
		if err != nil {
			return CompareResult{}, err
		}
	}
	if err := ensureBuildIsCurrent(p, modulePath, hakPaths); err != nil {
		return CompareResult{ModulePath: modulePath, HAKPaths: hakPaths}, err
	}

	moduleArchive, err := readArchive(modulePath)
	if err != nil {
		return CompareResult{}, err
	}

	expected, err := expectedResources(p)
	if err != nil {
		return CompareResult{}, err
	}

	actual := archiveIndex(moduleArchive)
	for _, hakPath := range hakPaths {
		hakArchive, err := readArchive(hakPath)
		if err != nil {
			return CompareResult{}, err
		}
		for key, value := range archiveIndex(hakArchive) {
			actual[key] = value
		}
	}

	var diagnostics []error
	checked := 0

	expectedKeys := make([]string, 0, len(expected))
	for key := range expected {
		expectedKeys = append(expectedKeys, key)
	}
	slices.Sort(expectedKeys)

	for _, key := range expectedKeys {
		want := expected[key]
		got, ok := actual[key]
		if !ok {
			diagnostics = append(diagnostics, fmt.Errorf("missing resource in built archives: %s", key))
			continue
		}
		if !bytes.Equal(want.Bytes, got.Data) {
			diagnostics = append(diagnostics, fmt.Errorf("resource content mismatch: %s", key))
			continue
		}
		checked++
		delete(actual, key)
	}

	if len(actual) > 0 {
		extraKeys := make([]string, 0, len(actual))
		for key := range actual {
			extraKeys = append(extraKeys, key)
		}
		slices.Sort(extraKeys)
		for _, key := range extraKeys {
			diagnostics = append(diagnostics, fmt.Errorf("unexpected extra resource in built archives: %s", key))
		}
	}

	result := CompareResult{
		ModulePath: modulePath,
		HAKPaths:   hakPaths,
		Checked:    checked,
	}
	if len(diagnostics) > 0 {
		return result, errors.Join(diagnostics...)
	}
	return result, nil
}

func expectedResources(p *project.Project) (map[string]resourceExpectation, error) {
	out := map[string]resourceExpectation{}
	moduleHakOrder, err := plannedModuleHAKOrder(p)
	if err != nil {
		return nil, err
	}

	for _, rel := range p.Inventory.SourceFiles {
		abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
		name, extension, err := splitSourceName(abs)
		if err != nil {
			return nil, err
		}

		raw, err := os.ReadFile(abs)
		if err != nil {
			return nil, fmt.Errorf("read %s: %w", abs, err)
		}
		var document gff.Document
		if err := json.Unmarshal(raw, &document); err != nil {
			return nil, fmt.Errorf("parse gff json %s: %w", abs, err)
		}
		if extension == ".ifo" && strings.EqualFold(name, "module") && len(moduleHakOrder) > 0 {
			setModuleHAKList(&document, moduleHakOrder)
		}

		canonical, err := json.Marshal(document)
		if err != nil {
			return nil, fmt.Errorf("marshal canonical gff json %s: %w", abs, err)
		}

		out[resourceKey(name, extension)] = resourceExpectation{
			Key:   resourceKey(name, extension),
			Kind:  "gff-json",
			Bytes: canonical,
		}
	}

	for _, rel := range p.Inventory.ScriptFiles {
		abs := filepath.Join(p.SourceDir(), filepath.FromSlash(rel))
		data, err := os.ReadFile(abs)
		if err != nil {
			return nil, fmt.Errorf("read %s: %w", abs, err)
		}
		name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)))
		extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
		out[resourceKey(name, extension)] = resourceExpectation{
			Key:   resourceKey(name, extension),
			Kind:  "raw",
			Bytes: data,
		}
	}

	compiledScripts, err := compileReferencedScripts(p)
	if err != nil {
		return nil, err
	}
	for _, resource := range compiledScripts {
		extension, ok := erf.ExtensionForResourceType(resource.Type)
		if !ok {
			return nil, fmt.Errorf("unsupported compiled resource type for %s", resource.Name)
		}
		key := resourceKey(resource.Name, extension)
		out[key] = resourceExpectation{
			Key:   key,
			Kind:  "compiled-script",
			Bytes: resource.Data,
		}
	}

	for _, rel := range p.Inventory.AssetFiles {
		abs := filepath.Join(p.AssetsDir(), filepath.FromSlash(rel))
		data, err := os.ReadFile(abs)
		if err != nil {
			return nil, fmt.Errorf("read %s: %w", abs, err)
		}
		name := strings.ToLower(strings.TrimSuffix(filepath.Base(abs), filepath.Ext(abs)))
		extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(abs)), ".")
		out[resourceKey(name, extension)] = resourceExpectation{
			Key:   resourceKey(name, extension),
			Kind:  "raw",
			Bytes: data,
		}
	}

	return out, nil
}

func ensureBuildIsCurrent(p *project.Project, modulePath string, hakPaths []string) error {
	archivePaths := make([]string, 0, 1+len(hakPaths))
	archivePaths = append(archivePaths, modulePath)
	archivePaths = append(archivePaths, hakPaths...)

	oldestArchive := time.Time{}
	for _, path := range archivePaths {
		info, err := os.Stat(path)
		if err != nil {
			return fmt.Errorf("stat archive %s: %w", path, err)
		}
		if oldestArchive.IsZero() || info.ModTime().Before(oldestArchive) {
			oldestArchive = info.ModTime()
		}
	}

	sourcePaths := make([]string, 0, len(p.Inventory.SourceFiles)+len(p.Inventory.ScriptFiles)+len(p.Inventory.AssetFiles))
	for _, rel := range p.Inventory.SourceFiles {
		sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
	}
	for _, rel := range p.Inventory.ScriptFiles {
		sourcePaths = append(sourcePaths, filepath.Join(p.SourceDir(), filepath.FromSlash(rel)))
	}
	for _, rel := range p.Inventory.AssetFiles {
		sourcePaths = append(sourcePaths, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
	}

	newestSource := time.Time{}
	newestPath := ""
	for _, path := range sourcePaths {
		info, err := os.Stat(path)
		if err != nil {
			return fmt.Errorf("stat source %s: %w", path, err)
		}
		if newestSource.IsZero() || info.ModTime().After(newestSource) {
			newestSource = info.ModTime()
			newestPath = path
		}
	}

	if !newestSource.IsZero() && newestSource.After(oldestArchive) {
		return fmt.Errorf("built archives are older than source file %s; run build-module or build-haks before compare", newestPath)
	}
	return nil
}

func readArchive(path string) (erf.Archive, error) {
	input, err := os.Open(path)
	if err != nil {
		return erf.Archive{}, fmt.Errorf("open archive %s: %w", path, err)
	}
	defer input.Close()

	archive, err := erf.Read(input)
	if err != nil {
		return erf.Archive{}, fmt.Errorf("read archive %s: %w", path, err)
	}
	return archive, nil
}

func archiveIndex(archive erf.Archive) map[string]erf.Resource {
	out := map[string]erf.Resource{}
	for _, resource := range archive.Resources {
		extension, ok := erf.ExtensionForResourceType(resource.Type)
		if !ok {
			continue
		}

		key := resourceKey(strings.ToLower(resource.Name), extension)
		switch extension {
		case "utc", "utd", "ute", "uti", "utm", "utp", "uts", "utt", "utw",
			"are", "dlg", "fac", "gic", "git", "ifo", "itp", "jrl":
			document, err := gff.Read(bytes.NewReader(resource.Data))
			if err != nil {
				out[key] = resource
				continue
			}
			canonical, err := json.Marshal(document)
			if err != nil {
				out[key] = resource
				continue
			}
			resource.Data = canonical
		}

		out[key] = resource
	}
	return out
}

func resourceKey(name, extension string) string {
	return strings.ToLower(name) + "." + strings.TrimPrefix(strings.ToLower(extension), ".")
}

func manifestHAKPaths(p *project.Project) ([]string, error) {
	manifestPath := p.HAKManifestPath()
	raw, err := os.ReadFile(manifestPath)
	if err == nil {
		var manifest BuildManifest
		if err := json.Unmarshal(raw, &manifest); err != nil {
			return nil, fmt.Errorf("parse hak manifest: %w", err)
		}
		paths := make([]string, 0, len(manifest.HAKs))
		for _, hak := range manifest.HAKs {
			paths = append(paths, p.HAKArchivePath(hak.Name))
		}
		return paths, nil
	}
	if !errors.Is(err, os.ErrNotExist) {
		return nil, fmt.Errorf("read hak manifest: %w", err)
	}

	legacy := filepath.Join(p.BuildDir(), "*.hak")
	paths, err := filepath.Glob(legacy)
	if err != nil {
		return nil, fmt.Errorf("scan hak archives: %w", err)
	}
	filtered := make([]string, 0, len(paths))
	for _, path := range paths {
		if filepath.Base(path) == p.TopDataPackageHAKName() {
			continue
		}
		filtered = append(filtered, path)
	}
	slices.Sort(filtered)
	return filtered, nil
}

func topPackageSourcePaths(p *project.Project) ([]string, error) {
	sourceDir := p.TopDataSourceDir()
	generated2DADir := filepath.Join(sourceDir, "assets", "2da")
	paths := make([]string, 0)
	for _, candidate := range []string{
		filepath.Join(sourceDir, ".tlk_state.json"),
		filepath.Join(sourceDir, "base_dialog.json"),
	} {
		if _, err := os.Stat(candidate); err == nil {
			paths = append(paths, candidate)
		} else if err != nil && !errors.Is(err, os.ErrNotExist) {
			return nil, fmt.Errorf("stat topdata source %s: %w", candidate, err)
		}
	}
	for _, dir := range []string{
		filepath.Join(sourceDir, "data"),
		filepath.Join(sourceDir, "assets"),
	} {
		info, err := os.Stat(dir)
		if err != nil {
			if errors.Is(err, os.ErrNotExist) {
				continue
			}
			return nil, fmt.Errorf("stat topdata path %s: %w", dir, err)
		}
		if !info.IsDir() {
			continue
		}
		err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
			if walkErr != nil {
				return walkErr
			}
			if d.IsDir() && path == generated2DADir {
				return filepath.SkipDir
			}
			if d.IsDir() {
				return nil
			}
			paths = append(paths, path)
			return nil
		})
		if err != nil {
			return nil, fmt.Errorf("scan topdata path %s: %w", dir, err)
		}
	}
	return paths, nil
}
