// Package skillsmanifest materializes the allowlisted AgentTeam Insight skills
// for one graph-builder file-analyzer working directory and refreshes the
// companion manifest. The materializer is the only path by which
// .agents/skills/agentteam-insight/* entries appear inside a graph run workDir,
// so reviewers can grep for non-allowlisted skill writes and treat them as
// contract violations.
package skillsmanifest

import (
	"bytes"
	"crypto/sha256"
	"embed"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"sync"
	"time"
)

//go:embed all:agentteam-insight
var skillFS embed.FS

const (
	// ManifestSchema is the schema version stamped into every written manifest.
	ManifestSchema = "agentteam-insight.graph.skills-manifest.v1"
	// BundleVersion is bumped whenever the bundled skill set changes shape.
	// Reviewers can diff this constant across releases to spot silent skill
	// swaps.
	BundleVersion = "2026.07.23.1"
	// SkillsRootDir is the directory name (relative to the workDir) under
	// which all materialized skills live. It matches opencode's
	// .agents/skills convention so existing agent runtimes discover the
	// allowlisted skills without extra configuration.
	SkillsRootDir = ".agents/skills"
	// AgentTeamInsightRootDir is the directory under SkillsRootDir that AgentTeam
	// Insight owns. pruneStaleSkills only touches entries inside this
	// subtree, so user- or host-provided skills outside it survive a
	// materialization pass.
	AgentTeamInsightRootDir = "agentteam-insight"

	manifestName = "skills-manifest.json"
)

// SkillSpec describes one allowlisted AgentTeam Insight skill.
type SkillSpec struct {
	// ID is the stable identifier written to the manifest. Callers should
	// never derive IDs from filesystem layout; new IDs come from this
	// package only.
	ID string
	// RelativePath is the path of the skill file under SkillsRootDir, using
	// forward slashes regardless of host OS.
	RelativePath string
	// SHA256 is the hex-encoded sha256 of the skill content. It is populated
	// by Allowed() and never changes for a given build.
	SHA256 string

	content []byte
}

// ManifestSkill is one entry in the written manifest.
type ManifestSkill struct {
	ID           string `json:"id"`
	RelativePath string `json:"relativePath"`
	SHA256       string `json:"sha256"`
}

// Manifest is the manifest object written next to materialized skills.
type Manifest struct {
	Schema        string          `json:"schema"`
	BundleVersion string          `json:"bundleVersion"`
	GeneratedAt   string          `json:"generatedAt"`
	Skills        []ManifestSkill `json:"skills"`
}

var (
	specOnce sync.Once
	specs    []SkillSpec
	specErr  error
)

// Allowed returns the allowlisted AgentTeam Insight skills with sha256 values
// populated. The same slice is returned on every call within one process;
// call sites can hold a reference for cheap comparisons.
func Allowed() ([]SkillSpec, error) {
	specOnce.Do(func() {
		entries, err := fs.ReadDir(skillFS, AgentTeamInsightRootDir)
		if err != nil {
			specErr = fmt.Errorf("read embedded agentteam-insight skills: %w", err)
			return
		}
		for _, entry := range entries {
			if !entry.IsDir() {
				continue
			}
			name := entry.Name()
			embedPath := filepath.ToSlash(filepath.Join(AgentTeamInsightRootDir, name, "SKILL.md"))
			data, err := skillFS.ReadFile(embedPath)
			if err != nil {
				specErr = fmt.Errorf("read embedded skill %s: %w", embedPath, err)
				return
			}
			sum := sha256.Sum256(data)
			specs = append(specs, SkillSpec{
				ID:           "agentteam-insight." + name,
				RelativePath: embedPath,
				SHA256:       hex.EncodeToString(sum[:]),
				content:      append([]byte(nil), data...),
			})
		}
		sort.Slice(specs, func(i, j int) bool { return specs[i].ID < specs[j].ID })
	})
	return specs, specErr
}

// Materialize writes the allowlisted skills into workDir and refreshes the
// manifest. The function is idempotent: if every target file already matches
// the embedded content and the manifest already matches the current
// allowlist + bundleVersion, no writes occur. Stale AgentTeam Insight skill
// entries under SkillsRootDir/AgentTeamInsightRootDir are removed; entries
// outside that subtree (for example user-provided skills) are preserved.
func Materialize(workDir string) (*Manifest, error) {
	if strings.TrimSpace(workDir) == "" {
		return nil, fmt.Errorf("workDir is required")
	}
	allowed, err := Allowed()
	if err != nil {
		return nil, err
	}
	skillsRoot := filepath.Join(workDir, SkillsRootDir)
	if err := os.MkdirAll(skillsRoot, 0o755); err != nil {
		return nil, fmt.Errorf("create skills dir: %w", err)
	}
	if err := pruneStaleAgentTeamInsightSkills(skillsRoot, allowed); err != nil {
		return nil, fmt.Errorf("prune stale skills: %w", err)
	}

	manifest := buildManifest(allowed, time.Now().UTC())
	manifestPath := filepath.Join(skillsRoot, manifestName)

	for _, spec := range allowed {
		target := filepath.Join(skillsRoot, filepath.FromSlash(spec.RelativePath))
		if err := writeSkillIfChanged(target, spec.content); err != nil {
			return nil, fmt.Errorf("materialize skill %s: %w", spec.ID, err)
		}
	}
	if err := writeManifestIfChanged(manifestPath, manifest); err != nil {
		return nil, fmt.Errorf("write skills manifest: %w", err)
	}
	return manifest, nil
}

// MatchesAllowed reports whether the existing skill directory contents in
// workDir exactly match the current allowlist. Callers use it as a cheap
// validator without performing a full re-materialization (for example, when
// validating a long-lived runner workDir between batches).
func MatchesAllowed(workDir string) (bool, error) {
	allowed, err := Allowed()
	if err != nil {
		return false, err
	}
	skillsRoot := filepath.Join(workDir, SkillsRootDir)
	manifestPath := filepath.Join(skillsRoot, manifestName)
	existing, err := loadManifest(manifestPath)
	if err != nil {
		return false, err
	}
	if existing == nil {
		return false, nil
	}
	if existing.Schema != ManifestSchema || existing.BundleVersion != BundleVersion {
		return false, nil
	}
	if len(existing.Skills) != len(allowed) {
		return false, nil
	}
	for i, want := range allowed {
		got := existing.Skills[i]
		if got.ID != want.ID || got.RelativePath != want.RelativePath || got.SHA256 != want.SHA256 {
			return false, nil
		}
	}
	for _, spec := range allowed {
		target := filepath.Join(skillsRoot, filepath.FromSlash(spec.RelativePath))
		data, err := os.ReadFile(target)
		if err != nil {
			return false, nil
		}
		if !bytes.Equal(data, spec.content) {
			return false, nil
		}
	}
	return true, nil
}

func buildManifest(allowed []SkillSpec, now time.Time) *Manifest {
	skills := make([]ManifestSkill, 0, len(allowed))
	for _, spec := range allowed {
		skills = append(skills, ManifestSkill{
			ID:           spec.ID,
			RelativePath: spec.RelativePath,
			SHA256:       spec.SHA256,
		})
	}
	return &Manifest{
		Schema:        ManifestSchema,
		BundleVersion: BundleVersion,
		GeneratedAt:   now.Format(time.RFC3339),
		Skills:        skills,
	}
}

func pruneStaleAgentTeamInsightSkills(skillsRoot string, allowed []SkillSpec) error {
	allowedTopLevel := map[string]struct{}{}
	for _, spec := range allowed {
		topLevel, ok := agentteamInsightTopLevelFolder(spec.RelativePath)
		if !ok {
			continue
		}
		allowedTopLevel[topLevel] = struct{}{}
	}
	insightRoot := filepath.Join(skillsRoot, AgentTeamInsightRootDir)
	entries, err := os.ReadDir(insightRoot)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return err
	}
	for _, entry := range entries {
		if !entry.IsDir() {
			continue
		}
		name := entry.Name()
		if _, ok := allowedTopLevel[name]; ok {
			continue
		}
		if err := os.RemoveAll(filepath.Join(insightRoot, name)); err != nil {
			return fmt.Errorf("remove stale skill %s: %w", name, err)
		}
	}
	return nil
}

func agentteamInsightTopLevelFolder(relativePath string) (string, bool) {
	parts := strings.Split(strings.TrimSpace(relativePath), "/")
	if len(parts) < 2 || parts[0] != AgentTeamInsightRootDir {
		return "", false
	}
	return parts[1], true
}

func writeSkillIfChanged(path string, content []byte) error {
	existing, err := os.ReadFile(path)
	if err == nil && bytes.Equal(existing, content) {
		return nil
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, append([]byte(nil), content...), 0o644)
}

func writeManifestIfChanged(path string, manifest *Manifest) error {
	desired, err := marshalManifest(manifest)
	if err != nil {
		return err
	}
	existing, err := os.ReadFile(path)
	if err == nil {
		// The manifest carries a GeneratedAt timestamp, but stale detection
		// still works because we only compare the deterministic parts
		// (schema, bundleVersion, skills list). The timestamp in the file
		// is overwritten only when the deterministic parts change.
		var existingManifest Manifest
		if json.Unmarshal(existing, &existingManifest) == nil {
			if existingManifest.Schema == manifest.Schema &&
				existingManifest.BundleVersion == manifest.BundleVersion &&
				skillsSliceEqual(existingManifest.Skills, manifest.Skills) {
				return nil
			}
		}
	}
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
		return err
	}
	return os.WriteFile(path, desired, 0o644)
}

func marshalManifest(manifest *Manifest) ([]byte, error) {
	data, err := json.MarshalIndent(manifest, "", "  ")
	if err != nil {
		return nil, err
	}
	return append(data, '\n'), nil
}

func skillsSliceEqual(a, b []ManifestSkill) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}

func loadManifest(path string) (*Manifest, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var manifest Manifest
	if err := json.Unmarshal(data, &manifest); err != nil {
		return nil, err
	}
	return &manifest, nil
}
