package scanner

import (
	"bytes"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"sort"
	"strings"
	"unicode/utf8"
)

type Options struct {
	Root       string
	IgnoreRoot string
}

type Result struct {
	Root                string              `json:"-"`
	Name                string              `json:"name"`
	RawDescription      string              `json:"-"`
	Description         string              `json:"description"`
	ReadmeHead          string              `json:"-"`
	ReadmeContent       string              `json:"-"`
	Files               []FileInfo          `json:"files"`
	Ignored             []IgnoreInfo        `json:"-"`
	TotalFiles          int                 `json:"totalFiles"`
	FilteredByIgnore    int                 `json:"filteredByIgnore"`
	EstimatedComplexity string              `json:"estimatedComplexity"`
	Languages           []string            `json:"languages"`
	Frameworks          []string            `json:"frameworks"`
	ImportMap           map[string][]string `json:"importMap"`
}

type FileInfo struct {
	Path        string   `json:"path"`
	AbsPath     string   `json:"-"`
	Size        int64    `json:"-"`
	Lines       int      `json:"sizeLines"`
	Language    string   `json:"language"`
	Category    Category `json:"fileCategory"`
	ContentHash string   `json:"-"`
}

type IgnoreInfo struct {
	Path   string `json:"path"`
	Reason string `json:"reason"`
}

func Scan(ctx context.Context, options Options) (Result, error) {
	root := strings.TrimSpace(options.Root)
	if root == "" {
		return Result{}, fmt.Errorf("scan root is required")
	}
	absRoot, err := filepath.Abs(root)
	if err != nil {
		return Result{}, fmt.Errorf("resolve scan root: %w", err)
	}
	ignoreRoot := strings.TrimSpace(options.IgnoreRoot)
	if ignoreRoot == "" {
		ignoreRoot = absRoot
	}
	absIgnoreRoot, err := filepath.Abs(ignoreRoot)
	if err != nil {
		return Result{}, fmt.Errorf("resolve ignore root: %w", err)
	}
	matcher, err := LoadIgnoreMatcherWithProjectRoot(absRoot, absIgnoreRoot)
	if err != nil {
		return Result{}, fmt.Errorf("load ignore rules: %w", err)
	}
	defaultMatcher := DefaultIgnoreMatcher()
	result := Result{Root: absRoot}
	languageSet := map[string]struct{}{}
	frameworkSet := map[string]struct{}{}

	candidates, fromGit, err := enumerateFiles(ctx, absRoot)
	if err != nil {
		return Result{}, err
	}
	if fromGit {
		for _, rel := range candidates {
			if err := ctx.Err(); err != nil {
				return Result{}, err
			}
			if err := scanCandidate(absRoot, rel, matcher, defaultMatcher, &result, languageSet, frameworkSet); err != nil {
				return Result{}, err
			}
		}
	} else {
		err = filepath.WalkDir(absRoot, func(path string, entry fs.DirEntry, walkErr error) error {
			if walkErr != nil {
				return walkErr
			}
			if err := ctx.Err(); err != nil {
				return err
			}
			if path == absRoot {
				return nil
			}
			rel, err := filepath.Rel(absRoot, path)
			if err != nil {
				return err
			}
			rel = filepath.ToSlash(rel)
			ignored, reason := matcher.Match(rel, entry.IsDir())
			if ignored {
				result.Ignored = append(result.Ignored, IgnoreInfo{Path: rel, Reason: reason})
				if countsFilteredByUserIgnore(rel, entry.IsDir(), reason, defaultMatcher) {
					if entry.IsDir() {
						count, err := countDefaultKeptFilesUnder(absRoot, rel, defaultMatcher)
						if err != nil {
							return err
						}
						result.FilteredByIgnore += count
					} else {
						result.FilteredByIgnore++
					}
				}
				if entry.IsDir() {
					return filepath.SkipDir
				}
				return nil
			}
			if entry.IsDir() {
				return nil
			}
			return scanCandidate(absRoot, rel, IgnoreMatcher{}, defaultMatcher, &result, languageSet, frameworkSet)
		})
		if err != nil {
			return Result{}, err
		}
	}

	sort.Slice(result.Files, func(i, j int) bool { return result.Files[i].Path < result.Files[j].Path })
	sort.Slice(result.Ignored, func(i, j int) bool { return result.Ignored[i].Path < result.Ignored[j].Path })
	result.Languages = sortedKeys(languageSet)
	result.Frameworks = sortedKeys(frameworkSet)
	ApplyNarrative(absRoot, &result)
	FinalizeResult(&result)
	return result, nil
}

func enumerateFiles(ctx context.Context, root string) ([]string, bool, error) {
	if files, ok, err := enumerateViaGit(ctx, root); err != nil {
		return nil, false, err
	} else if ok {
		return files, true, nil
	}
	return nil, false, nil
}

func enumerateViaGit(ctx context.Context, root string) ([]string, bool, error) {
	cmd := exec.CommandContext(ctx, "git", "ls-files", "-z", "-co", "--exclude-standard")
	cmd.Dir = root
	out, err := cmd.Output()
	if err != nil || len(out) == 0 {
		return nil, false, nil
	}
	parts := bytes.Split(out, []byte{0})
	files := make([]string, 0, len(parts))
	for _, part := range parts {
		if len(part) == 0 {
			continue
		}
		files = append(files, filepath.ToSlash(string(part)))
	}
	sort.Strings(files)
	return files, true, nil
}

func scanCandidate(absRoot string, rel string, matcher IgnoreMatcher, defaultMatcher IgnoreMatcher, result *Result, languageSet map[string]struct{}, frameworkSet map[string]struct{}) error {
	rel = filepath.ToSlash(rel)
	if ignored, reason := matcher.Match(rel, false); ignored {
		result.Ignored = append(result.Ignored, IgnoreInfo{Path: rel, Reason: reason})
		if countsFilteredByUserIgnore(rel, false, reason, defaultMatcher) {
			result.FilteredByIgnore++
		}
		return nil
	}
	path := filepath.Join(absRoot, filepath.FromSlash(rel))
	info, err := os.Stat(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	if !info.Mode().IsRegular() {
		return nil
	}
	content, err := os.ReadFile(path)
	if err != nil {
		return err
	}
	if isBinary(content) {
		result.Ignored = append(result.Ignored, IgnoreInfo{Path: rel, Reason: "binary file"})
		return nil
	}
	language, category := ClassifyPath(rel)
	language = strings.ToLower(strings.TrimSpace(language))
	if language == "" {
		language = "unknown"
	}
	if language != "" {
		languageSet[language] = struct{}{}
	}
	for _, framework := range frameworkHints(rel, content) {
		frameworkSet[framework] = struct{}{}
	}
	result.Files = append(result.Files, FileInfo{
		Path:        rel,
		AbsPath:     path,
		Size:        info.Size(),
		Lines:       countLines(content),
		Language:    language,
		Category:    category,
		ContentHash: contentHash(content),
	})
	return nil
}

// FinalizeResult fills derived scan-result contract fields after callers add
// late Phase-1 data such as importMap or after generated-artifact filtering.
func FinalizeResult(result *Result) {
	for i := range result.Files {
		result.Files[i].Path = filepath.ToSlash(result.Files[i].Path)
		result.Files[i].Language = strings.ToLower(strings.TrimSpace(result.Files[i].Language))
		if result.Files[i].Language == "" {
			result.Files[i].Language, _ = ClassifyPath(result.Files[i].Path)
			result.Files[i].Language = strings.ToLower(strings.TrimSpace(result.Files[i].Language))
		}
		if result.Files[i].Language == "" {
			result.Files[i].Language = "unknown"
		}
		if result.Files[i].Category == "" {
			_, result.Files[i].Category = ClassifyPath(result.Files[i].Path)
		}
	}
	result.TotalFiles = len(result.Files)
	result.EstimatedComplexity = EstimateComplexity(result.TotalFiles)
	result.Languages = normalizeSortedStrings(result.Languages)
	result.Frameworks = normalizeSortedStrings(result.Frameworks)
	if result.ImportMap == nil {
		result.ImportMap = map[string][]string{}
	}
	for _, file := range result.Files {
		path := filepath.ToSlash(file.Path)
		if _, ok := result.ImportMap[path]; !ok {
			result.ImportMap[path] = []string{}
		}
	}
	if strings.TrimSpace(result.Name) == "" {
		result.Name = "project"
	}
	if result.Languages == nil {
		result.Languages = []string{}
	}
	if result.Frameworks == nil {
		result.Frameworks = []string{}
	}
}

// EstimateComplexity uses the graph builder file-count bands.
func EstimateComplexity(totalFiles int) string {
	switch {
	case totalFiles <= 30:
		return "small"
	case totalFiles <= 150:
		return "moderate"
	case totalFiles <= 500:
		return "large"
	default:
		return "very-large"
	}
}

func normalizeSortedStrings(values []string) []string {
	set := map[string]struct{}{}
	for _, value := range values {
		value = strings.TrimSpace(value)
		if value != "" {
			set[value] = struct{}{}
		}
	}
	return sortedKeys(set)
}

func countDefaultKeptFilesUnder(absRoot string, rel string, defaultMatcher IgnoreMatcher) (int, error) {
	root := filepath.Join(absRoot, filepath.FromSlash(rel))
	count := 0
	err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		if path == root {
			return nil
		}
		childRel, err := filepath.Rel(absRoot, path)
		if err != nil {
			return err
		}
		childRel = filepath.ToSlash(childRel)
		if ignored, _ := defaultMatcher.Match(childRel, entry.IsDir()); ignored {
			if entry.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if entry.IsDir() {
			return nil
		}
		info, err := entry.Info()
		if err != nil {
			return err
		}
		if info.Mode().IsRegular() {
			count++
		}
		return nil
	})
	return count, err
}

func countsFilteredByUserIgnore(rel string, isDir bool, reason string, defaultMatcher IgnoreMatcher) bool {
	if !strings.Contains(reason, ".agentteam-insightignore") {
		return false
	}
	ignoredByDefault, _ := defaultMatcher.Match(rel, isDir)
	return !ignoredByDefault
}

func WriteIntermediate(path string, result Result) error {
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	data, err := json.MarshalIndent(result, "", "  ")
	if err != nil {
		return err
	}
	data = append(data, '\n')
	return os.WriteFile(path, data, 0o644)
}

func isBinary(content []byte) bool {
	if len(content) == 0 {
		return false
	}
	if bytes.IndexByte(content, 0) >= 0 {
		return true
	}
	limit := len(content)
	if limit > 4096 {
		limit = 4096
	}
	return !utf8.Valid(content[:limit])
}

func countLines(content []byte) int {
	return bytes.Count(content, []byte{'\n'})
}

func contentHash(content []byte) string {
	sum := sha256.Sum256(content)
	return hex.EncodeToString(sum[:])
}

func sortedKeys(set map[string]struct{}) []string {
	keys := make([]string, 0, len(set))
	for key := range set {
		keys = append(keys, key)
	}
	sort.Strings(keys)
	return keys
}

func frameworkHints(rel string, content []byte) []string {
	lower := strings.ToLower(filepath.ToSlash(rel))
	text := strings.ToLower(string(content))
	hints := map[string]struct{}{}
	switch filepath.Base(lower) {
	case "go.mod":
		if strings.Contains(text, "github.com/gin-gonic/gin") {
			hints["gin"] = struct{}{}
		}
		if strings.Contains(text, "connectrpc.com/connect") {
			hints["connect"] = struct{}{}
		}
	case "package.json":
		for _, name := range []string{"react", "vue", "svelte", "next", "vite", "express"} {
			if strings.Contains(text, "\""+name+"\"") {
				hints[name] = struct{}{}
			}
		}
	}
	return sortedKeys(hints)
}
