package installsource

import (
	"bytes"
	"fmt"
	"io"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"

	"reasonix/internal/config"
	fileencoding "reasonix/internal/fileutil/encoding"
	"reasonix/internal/frontmatter"
	"reasonix/internal/skill"
)

const (
	maxSkillScanDepth = 3
	maxSkillScanCount = 200
	maxSkillCopyBytes = 20 << 20
)

// skillAction builds the DTO for a single-skill install (copy or link).
func (t *installSourceTool) skillAction(req request, cand skillCandidate, mode string) action {
	scope := t.installScope(req, "skill", cand.SourcePath)
	actionName := "copy_skill"
	if mode == "link" {
		actionName = "link_skill"
	}
	canonical, _ := t.skillCanonicalPath(cand.Name, scope)
	root, _ := t.skillInstallRoot(scope)
	a := action{
		Kind:          "skill",
		Action:        actionName,
		Name:          cand.Name,
		Source:        cand.SourcePath,
		Target:        canonical,
		Scope:         scope,
		Mode:          mode,
		ConfigPath:    t.configPath(scope),
		Skills:        []string{cand.Name},
		SkillCount:    1,
		Layout:        "canonical_dir",
		InstallRoot:   root,
		CanonicalPath: canonical,
		skill:         cand,
	}
	a.RiskLevel, a.RiskReasons = skillActionRisk(mode, cand)
	if mode == "link" && !isLinkTargetSafe(cand.SourcePath, t.home, t.root) {
		a.RiskLevel = RiskHigh
		a.RiskReasons = append(a.RiskReasons, "link target is an absolute path outside the project or home root")
	}
	return a
}

// skillActionRisk explains the risk budget for a skill install. The model
// uses this to decide whether to call apply=true directly or to ask first.
func skillActionRisk(mode string, cand skillCandidate) (RiskLevel, []string) {
	reasons := []string{}
	level := RiskLow
	if mode == "link" {
		// Link installs a pointer into a foreign tree; an untrusted source
		// could expose anything at runtime, so we always classify as medium
		// at minimum.
		level = RiskMedium
		reasons = append(reasons, "symlink to a foreign path")
	}
	if cand.IsDir && mode == "copy" {
		if level == RiskLow {
			level = RiskMedium
		}
		reasons = append(reasons, "copy of a directory")
	}
	return level, reasons
}

// skillRootAction builds the DTO for registering a whole skill directory.
func (t *installSourceTool) skillRootAction(req request, path string, names []string) action {
	scope := t.installScope(req, "skill", path)
	return action{
		Kind:        "skill",
		Action:      "register_skill_root",
		Name:        "",
		Source:      path,
		Target:      path,
		ConfigPath:  t.configPath(scope),
		Scope:       scope,
		Mode:        "register",
		Skills:      names,
		SkillCount:  len(names),
		Layout:      "registered_root",
		InstallRoot: path,
		RiskLevel:   RiskMedium,
		RiskReasons: []string{"adds a new skill root to the active config"},
	}
}

func (t *installSourceTool) skillInstallRoot(scope string) (string, error) {
	if scope == "global" {
		if t.reasonixHome == "" {
			return "", newErr(ErrSourceUnreadable, "global skill install requires a Reasonix home directory")
		}
		return filepath.Join(t.reasonixHome, skill.SkillsDirname), nil
	}
	return filepath.Join(t.root, ".reasonix", skill.SkillsDirname), nil
}

// skillCanonicalPath computes the canonical install destination:
// <scope>/skills/<skill-name>/SKILL.md. Flat <name>.md remains readable for
// backward compatibility, but the installer no longer writes it by default.
func (t *installSourceTool) skillCanonicalPath(name, scope string) (string, error) {
	if !config.IsValidSkillName(name) {
		return "", newErr(ErrInvalidManifest, "invalid skill name %q", name)
	}
	root, err := t.skillInstallRoot(scope)
	if err != nil {
		return "", err
	}
	return filepath.Join(root, name, skill.SkillFile), nil
}

// verifySkill confirms the installed skill is reachable through a freshly
// built Store. It is the post-install guard against partial failures.
func (t *installSourceTool) verifySkill(scope, name string, act *action) error {
	custom := []string(nil)
	if scope == "project" {
		cfg := config.LoadForEdit(filepath.Join(t.root, "reasonix.toml"))
		custom = cfg.SkillCustomPaths()
	} else {
		cfg := config.LoadForEdit(t.configPath(scope))
		custom = cfg.SkillCustomPaths()
	}
	var stderr bytes.Buffer
	store := skill.New(skill.Options{HomeDir: t.home, ReasonixHomeDir: t.reasonixHome, ProjectRoot: t.root, CustomPaths: custom, DisableBuiltins: true, Stderr: &stderr})
	sk, ok := store.Read(name)
	if !ok {
		return newErr(ErrSourceUnreadable, "skill %q is installed but not discoverable", name)
	}
	act.Discoverable = true
	act.CanonicalPath = sk.Path
	for _, listed := range store.List() {
		if listed.Name == name {
			act.Indexed = true
			break
		}
	}
	if strings.TrimSpace(sk.Description) == "" {
		act.Warnings = append(act.Warnings, fmt.Sprintf("skill %q has no description frontmatter; it is installed but the skills index will use a placeholder", name))
	}
	if msg := strings.TrimSpace(stderr.String()); msg != "" {
		act.Warnings = append(act.Warnings, msg)
	}
	return nil
}

// skillConflictTargets returns every existing layout that would collide with
// installing name: the canonical directory, its SKILL.md, and the legacy flat
// file. The apply step checks all of them so new canonical installs don't
// silently shadow older <name>.md installs.
func (t *installSourceTool) skillConflictTargets(name, scope string) ([]string, error) {
	canonical, err := t.skillCanonicalPath(name, scope)
	if err != nil {
		return nil, err
	}
	dir := filepath.Dir(canonical)
	return []string{dir, canonical, filepath.Join(filepath.Dir(dir), name+".md")}, nil
}

// readSkillFile reads and validates a single skill file. The fallback name
// is used when the frontmatter does not declare one.
func readSkillFile(path, fallbackName string, strict bool) (skillCandidate, error) {
	b, err := fileencoding.ReadFileUTF8(path)
	if err != nil {
		return skillCandidate{}, err
	}
	cand, err := parseSkillContent(string(b), fallbackName, path, strict)
	if err != nil {
		return skillCandidate{}, err
	}
	cand.SourcePath = path
	return cand, nil
}

// parseSkillContent validates the YAML frontmatter of a skill file. With
// strict=true (the default) we require a `name` and a `description`; with
// strict=false a missing description is allowed and the body may be empty —
// useful for installing raw files the user already trusts.
func parseSkillContent(content, fallbackName, source string, strict bool) (skillCandidate, error) {
	bom := "\uFEFF"
	content = strings.TrimPrefix(strings.ReplaceAll(content, "\r\n", "\n"), bom)
	var meta struct {
		Name        string `yaml:"name"`
		Description string `yaml:"description"`
	}
	body, err := frontmatter.Decode(content, &meta, frontmatter.DecodeOptions{})
	if err != nil {
		return skillCandidate{}, newErr(ErrInvalidManifest, "skill frontmatter at %s is invalid YAML: %v", source, err)
	}
	fm, _ := frontmatter.Split(content)
	name := strings.TrimSpace(fallbackName)
	if v := strings.TrimSpace(meta.Name); v != "" {
		name = v
	} else if v := strings.TrimSpace(fm["name"]); v != "" {
		name = v
	}
	if !config.IsValidSkillName(name) {
		return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an invalid name", name, source)
	}
	desc := collapseSpaces(meta.Description)
	if desc == "" {
		desc = collapseSpaces(fm["description"])
	}
	if strict {
		if desc == "" {
			return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s is missing description frontmatter", name, source)
		}
		if strings.TrimSpace(body) == "" {
			return skillCandidate{}, newErr(ErrInvalidManifest, "skill %q at %s has an empty body", name, source)
		}
	}
	return skillCandidate{Name: name, Description: desc, SourcePath: source, Content: content}, nil
}

// scanSkillRoot enumerates skills under a directory with bounded recursion:
// any <name>/SKILL.md is a directory-layout skill, and any <name>.md is a
// flat compatibility skill. RootPath records the containing directory that must
// be registered for the runtime Store to discover that candidate.
func scanSkillRoot(root string, strict bool) ([]skillCandidate, error) {
	var out []skillCandidate
	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if path == root {
			return nil
		}
		rel, err := filepath.Rel(root, path)
		if err != nil {
			return err
		}
		depth := pathDepth(rel)
		if d.IsDir() {
			if depth > maxSkillScanDepth {
				return filepath.SkipDir
			}
			if strings.EqualFold(d.Name(), ".git") {
				return filepath.SkipDir
			}
			return nil
		}
		if len(out) >= maxSkillScanCount {
			return newErr(ErrInvalidManifest, "too many skills under %s; limit is %d", root, maxSkillScanCount)
		}
		if !d.Type().IsRegular() || !strings.EqualFold(filepath.Ext(d.Name()), ".md") {
			return nil
		}
		parent := filepath.Dir(path)
		if strings.EqualFold(d.Name(), skill.SkillFile) {
			containerDepth := pathDepth(mustRel(root, parent))
			if containerDepth > maxSkillScanDepth {
				return nil
			}
			if parent == root {
				return nil
			}
			cand, err := readSkillFile(path, filepath.Base(parent), strict)
			if err == nil {
				cand.IsDir = true
				cand.SourcePath = parent
				cand.RootPath = filepath.Dir(parent)
				out = append(out, cand)
			}
			return nil
		}
		containerDepth := pathDepth(mustRel(root, parent))
		if containerDepth > maxSkillScanDepth {
			return nil
		}
		stem := strings.TrimSuffix(d.Name(), filepath.Ext(d.Name()))
		cand, err := readSkillFile(path, stem, strict)
		if err == nil {
			cand.RootPath = parent
			out = append(out, cand)
		}
		return nil
	})
	if err != nil {
		return nil, err
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
	return out, nil
}

func pathDepth(rel string) int {
	if rel == "." || rel == "" {
		return 0
	}
	return len(strings.Split(filepath.Clean(rel), string(filepath.Separator)))
}

func mustRel(base, path string) string {
	rel, err := filepath.Rel(base, path)
	if err != nil {
		return "."
	}
	return rel
}

// copyDir walks src and writes a parallel tree under dst. O_EXCL refuses to
// overwrite a leaf; a leftover partial tree is left on disk for the user to
// inspect (we never rm -rf). A symlink is materialized as its resolved
// content only when it points at a regular file INSIDE src — discovery
// (skills and commands) follows links, so dropping an in-tree alias would
// install less than the plan counted. Everything else a link can be —
// escaping the tree, broken, or a directory — is skipped, never followed:
// the plugin-package apply path verifies capability counts after the copy,
// so a skipped link fails the install closed instead of silently shrinking it.
func copyDir(src, dst string) error {
	var copied int64
	srcRoot := src
	if resolved, err := filepath.EvalSymlinks(src); err == nil {
		srcRoot = resolved
	}
	return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		if d.IsDir() {
			if strings.EqualFold(d.Name(), ".git") {
				return filepath.SkipDir
			}
			return os.MkdirAll(target, 0o755)
		}
		source := path
		if !d.Type().IsRegular() {
			if d.Type()&os.ModeSymlink == 0 {
				return nil
			}
			resolved, err := filepath.EvalSymlinks(path)
			if err != nil {
				return nil // broken link
			}
			relToRoot, err := filepath.Rel(srcRoot, resolved)
			if err != nil || relToRoot == ".." || strings.HasPrefix(relToRoot, ".."+string(filepath.Separator)) {
				return nil // escapes the tree — never follow
			}
			info, err := os.Stat(resolved)
			if err != nil || !info.Mode().IsRegular() {
				return nil // directory or otherwise unmaterializable link
			}
			source = resolved
		}
		info, err := os.Stat(source)
		if err != nil {
			return err
		}
		copied += info.Size()
		if copied > maxSkillCopyBytes {
			return newErr(ErrInvalidManifest, "skill directory exceeds %d bytes", maxSkillCopyBytes)
		}
		in, err := os.Open(source)
		if err != nil {
			return err
		}
		defer in.Close()
		if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
			return err
		}
		mode := os.FileMode(0o644)
		if info.Mode().Perm()&0o111 != 0 {
			// Plugin hook binaries and scripts must remain executable after copy.
			// Normalize to ordinary executable permissions instead of carrying
			// special source mode bits such as setuid/setgid into the install.
			mode = 0o755
		}
		out, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode)
		if err != nil {
			return err
		}
		defer out.Close()
		_, err = io.Copy(out, in)
		return err
	})
}

func writeNewFile(path string, content []byte) error {
	f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
	if err != nil {
		return err
	}
	defer f.Close()
	if _, err := f.Write(content); err != nil {
		return err
	}
	return nil
}
