package install

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"io/fs"
	"maps"
	"os"
	"path/filepath"
	"slices"
	"sort"
	"strings"

	"github.com/devrites/devrites/internal/hostpack"
)

func (r *runner) preflightInstall() error {
	desired, err := r.desiredInstallPaths()
	if err != nil {
		return fmt.Errorf("preflight install paths: %w", err)
	}
	var conflicts []string
	dryTree := map[string][]byte{}
	for rel := range desired {
		snapshot, err := r.rememberPath(rel)
		if err != nil {
			return err
		}
		record, managed := r.prev[rel]
		if managed && !snapshot.missing {
			if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
				conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
			}
		}
	}
	for _, rel := range slices.Sorted(maps.Keys(r.prev)) {
		record := r.prev[rel]
		if desired[rel] || hostpack.PreserveOnPrune(rel) {
			continue
		}
		snapshot, err := r.rememberPath(rel)
		if err != nil {
			return err
		}
		if !snapshot.missing && r.isPinnedAlias(rel) {
			// Carry pins without adopting edits; only legacy pins need a baseline.
			if record.Hash == "" {
				record.Hash = snapshot.hash
			}
			if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
				conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
			}
			r.addManifest(rel)
			r.addInstallRecord(rel, record.Hash)
			continue
		}
		if !snapshot.missing {
			if kind := managedConflict(record, snapshot); kind != "" && !r.opts.Force {
				conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
			}
		}
		if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
			if _, err := r.rememberPath(merge.TargetRel); err != nil {
				return err
			}
			if err := r.dryRunStripMarker(dryTree, merge); err != nil {
				return err
			}
		}
	}
	for _, rel := range r.installMergeTargets() {
		if _, err := r.rememberPath(rel); err != nil {
			return err
		}
	}
	if _, err := r.rememberPath(ManifestName); err != nil {
		return err
	}
	if err := managedConflictError(conflicts); err != nil {
		return err
	}
	return r.preflightMerges()
}

// preflightMerges computes every config merge against the current files, so
// a merge the installer would reject fails before anything is written.
func (r *runner) preflightMerges() error {
	if !r.opts.WithSkills {
		return nil
	}
	for _, m := range []struct {
		enabled bool
		merge   hostpack.MarkerMerge
	}{
		{r.opts.WithCodex, hostpack.CodexAgentsMerge},
		{r.opts.WithPi, hostpack.PiAgentsMerge},
		{r.opts.WithDevin, hostpack.DevinAgentsMerge},
		{r.opts.WithDsh, hostpack.DshAgentsMerge},
	} {
		if !m.enabled {
			continue
		}
		if _, _, err := r.markerMergeNext(m.merge); err != nil {
			return fmt.Errorf("merge %s: %w", m.merge.TargetRel, err)
		}
		if err := r.preflightMarkerOwnership(m.merge.TargetRel, m.merge.MarkerRel, m.merge.MarkerText); err != nil {
			return err
		}
	}
	if r.opts.WithCodex {
		if _, err := r.codexConfigNext(); err != nil {
			return fmt.Errorf("merge %s: %w", hostpack.CodexConfigMerge.TargetRel, err)
		}
		merge := hostpack.CodexConfigMerge
		if err := r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, merge.MarkerText); err != nil {
			return err
		}
	}
	_, settings, err := r.claudeSettingsNext()
	if err != nil {
		return fmt.Errorf("seed claude settings: %w", err)
	}
	merge := hostpack.ClaudeSettingsMerge
	return r.preflightMarkerOwnership(merge.TargetRel, merge.MarkerRel, claudeSettingsMarkerText(r.pendingClaudeSettingsOwnership(settings)))
}

func (r *runner) preflightMarkerOwnership(target, marker, text string) error {
	if snapshot := r.preflight[marker]; !snapshot.missing && !r.opts.Force {
		if _, managed := r.prev[marker]; !managed {
			sum := sha256.Sum256([]byte(text + "\n"))
			if snapshot.hash != fmt.Sprintf("sha256:%x", sum[:]) {
				return fmt.Errorf("cannot merge %s without owning %s; use --force to replace the marker", target, marker)
			}
		}
	}
	return nil
}

func (r *runner) preflightUninstall(entries []string) error {
	var conflicts []string
	dryTree := map[string][]byte{}
	for _, rel := range entries {
		if merge, ok := hostpack.ManagedMergeForMarker(rel); ok {
			if _, err := r.rememberPath(merge.TargetRel); err != nil {
				return err
			}
			if err := r.dryRunStripMarker(dryTree, merge); err != nil {
				return err
			}
		}
		if !hostpack.ShouldRemoveOnUninstall(rel, entries) {
			continue
		}
		snapshot, err := r.rememberPath(rel)
		if err != nil {
			return err
		}
		if snapshot.missing {
			continue
		}
		if kind := managedConflict(r.prev[rel], snapshot); kind != "" && !r.opts.Force {
			conflicts = append(conflicts, fmt.Sprintf("%s: %s", kind, rel))
		}
	}
	if _, err := r.rememberPath(ManifestName); err != nil {
		return err
	}
	return managedConflictError(conflicts)
}

// isPinnedAlias reports whether a dropped manifest entry is a scripts/pin.sh
// alias wrapper: <skills>/<name>/SKILL.md for a name that is not a built-in
// alias, delegating with the alias description.
func (r *runner) isPinnedAlias(rel string) bool {
	var name string
	for _, prefix := range []string{hostpack.ClaudeSkillsTarget, hostpack.CodexSkillsTarget} {
		if rest, ok := strings.CutPrefix(rel, prefix+"/"); ok {
			name, ok = strings.CutSuffix(rest, "/SKILL.md")
			if !ok || strings.Contains(name, "/") {
				return false
			}
		}
	}
	if name == "" || slices.ContainsFunc(hostpack.Aliases, func(a hostpack.Alias) bool { return a.Name == name }) {
		return false
	}
	// #nosec G304 -- manifest entry under the operator target, path-checked by rememberPath
	data, err := os.ReadFile(filepath.Join(r.target, filepath.FromSlash(rel)))
	return err == nil && strings.Contains(string(data), "\ndescription: Alias of DevRites /")
}

func (r *runner) desiredInstallPaths() (map[string]bool, error) {
	out := map[string]bool{".devrites/README.md": true}
	for _, tree := range hostpack.InstallTrees(r.opts.WithSkills, r.opts.WithAgents, r.opts.WithCodex, r.opts.WithOmp, r.opts.WithPi, r.opts.WithDevin, r.opts.WithDsh) {
		err := fs.WalkDir(r.payloadFS, tree.PayloadPrefix, func(path string, d fs.DirEntry, err error) error {
			if err != nil {
				return err
			}
			if d.IsDir() {
				return nil
			}
			rel, err := filepath.Rel(tree.PayloadPrefix, path)
			if err != nil {
				return err
			}
			out[filepath.ToSlash(filepath.Join(tree.TargetPrefix, rel))] = true
			return nil
		})
		if err != nil {
			return nil, err
		}
	}
	if r.opts.WithSkills && r.opts.AliasMode == "all" {
		for _, alias := range hostpack.Aliases {
			for _, rel := range hostpack.AliasTargets(alias, r.opts.WithCodex, r.opts.WithOmp, r.opts.WithPi, r.opts.WithDevin, r.opts.WithDsh) {
				out[rel] = true
			}
		}
	}
	if r.opts.WithSkills && r.opts.WithCodex {
		out[hostpack.CodexAgentsMerge.MarkerRel] = true
		out[hostpack.CodexConfigMerge.MarkerRel] = true
	}
	if r.opts.WithSkills && r.opts.WithPi {
		out[hostpack.PiAgentsMerge.MarkerRel] = true
	}
	if r.opts.WithSkills && r.opts.WithDevin {
		out[hostpack.DevinAgentsMerge.MarkerRel] = true
	}
	if r.opts.WithSkills && r.opts.WithDsh {
		out[hostpack.DshAgentsMerge.MarkerRel] = true
	}
	if r.opts.WithSkills {
		out[hostpack.ClaudeSettingsMerge.MarkerRel] = true
	}
	for _, config := range r.hostConfigs() {
		out[config.Rel] = true
	}
	return out, nil
}

func (r *runner) installMergeTargets() []string {
	var out []string
	if r.opts.WithSkills {
		out = append(out, hostpack.ClaudeSettingsMerge.TargetRel)
		if r.opts.WithCodex {
			out = append(out, hostpack.CodexAgentsMerge.TargetRel, hostpack.CodexConfigMerge.TargetRel)
		}
		if r.opts.WithPi {
			out = append(out, hostpack.PiAgentsMerge.TargetRel)
		}
		if r.opts.WithDevin {
			out = append(out, hostpack.DevinAgentsMerge.TargetRel)
		}
		if r.opts.WithDsh {
			out = append(out, hostpack.DshAgentsMerge.TargetRel)
		}
	}
	return out
}

func (r *runner) rememberPath(rel string) (pathSnapshot, error) {
	rel = filepath.ToSlash(rel)
	if r.targetInfo == nil {
		info, err := os.Stat(r.target)
		if err != nil {
			return pathSnapshot{}, err
		}
		r.targetInfo = info
	}
	if snapshot, ok := r.preflight[rel]; ok {
		return snapshot, nil
	}
	snapshot, err := r.inspectManagedPath(rel)
	if err != nil {
		return pathSnapshot{}, err
	}
	r.preflight[rel] = snapshot
	return snapshot, nil
}

// refreshPreflight replaces a recorded snapshot after the runner's own
// verified write (marker merge, strip). Needed when several managed merges
// share one target such as AGENTS.md.
func (r *runner) refreshPreflight(rel string) error {
	snap, err := r.inspectManagedPath(rel)
	if err != nil {
		return err
	}
	r.preflight[filepath.ToSlash(rel)] = snap
	return nil
}

func (r *runner) recheckPath(rel string) error {
	if err := r.checkTargetIdentity(); err != nil {
		return err
	}
	rel = filepath.ToSlash(rel)
	before, ok := r.preflight[rel]
	if !ok {
		return fmt.Errorf("path %s was not preflighted", rel)
	}
	now, err := r.inspectManagedPath(rel)
	if err != nil {
		return err
	}
	if now != before {
		return fmt.Errorf("refusing to change %s: file changed after preflight; retry the operation", rel)
	}
	if afterRecheckPath != nil {
		afterRecheckPath(rel)
		if err := r.checkTargetIdentity(); err != nil {
			return err
		}
	}
	return nil
}

// afterRecheckPath lets tests swap a path component in the window between the
// recheck and the write that follows it.
var afterRecheckPath func(rel string)

func (r *runner) inspectManagedPath(rel string) (pathSnapshot, error) {
	if r.targetRoot != nil {
		return inspectManagedPathIn(r.targetRoot, rel)
	}
	return inspectManagedPath(r.target, rel)
}

func inspectManagedPath(target, rel string) (pathSnapshot, error) {
	root, err := os.OpenRoot(target)
	if err != nil {
		return pathSnapshot{}, err
	}
	defer func() { _ = root.Close() }()
	return inspectManagedPathIn(root, rel)
}

func inspectManagedPathIn(root *os.Root, rel string) (pathSnapshot, error) {
	native := filepath.FromSlash(rel)
	if !filepath.IsLocal(native) {
		return pathSnapshot{}, fmt.Errorf("refusing unsafe managed path %q", rel)
	}
	if root == nil {
		return pathSnapshot{}, fmt.Errorf("install target is not open")
	}
	walk := ""
	parts := strings.Split(native, string(filepath.Separator))
	for _, part := range parts {
		walk = filepath.Join(walk, part)
		info, err := root.Lstat(walk)
		if os.IsNotExist(err) {
			return pathSnapshot{missing: true}, nil
		}
		if err != nil {
			return pathSnapshot{}, fmt.Errorf("inspect managed path %s: %w", rel, err)
		}
		if info.Mode()&os.ModeSymlink != 0 {
			return pathSnapshot{}, fmt.Errorf("refusing symlink or junction in managed path: %s", rel)
		}
	}
	info, err := root.Lstat(native)
	if os.IsNotExist(err) {
		return pathSnapshot{missing: true}, nil
	}
	if err != nil {
		return pathSnapshot{}, fmt.Errorf("inspect managed path %s: %w", rel, err)
	}
	if !info.Mode().IsRegular() {
		return pathSnapshot{}, fmt.Errorf("refusing non-regular managed path: %s", rel)
	}
	f, err := root.Open(native)
	if err != nil {
		return pathSnapshot{}, fmt.Errorf("read managed path %s: %w", rel, err)
	}
	defer func() { _ = f.Close() }()
	sum := sha256.New()
	if _, err := io.Copy(sum, f); err != nil {
		return pathSnapshot{}, fmt.Errorf("hash managed path %s: %w", rel, err)
	}
	return pathSnapshot{hash: "sha256:" + hex.EncodeToString(sum.Sum(nil))}, nil
}

func managedConflict(record managedRecord, snapshot pathSnapshot) string {
	if record.Hash == "" {
		return "legacy manifest entry (no hash)"
	}
	if record.Hash != snapshot.hash {
		return "customized managed file"
	}
	return ""
}

func managedConflictError(conflicts []string) error {
	if len(conflicts) == 0 {
		return nil
	}
	sort.Strings(conflicts)
	return fmt.Errorf("managed files differ from the install manifest:\n  %s\nrerun with --force to replace or remove these files", strings.Join(conflicts, "\n  "))
}
