// Package hookutil holds small filesystem helpers shared by the agent hook
// installers (claude-code, codex, opencode). It centralizes the atomic-write
// primitive so every adapter writes hook config the same crash-safe way.
package hookutil

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"
	"strings"
)

// GitignoreSentinel marks a workspace .gitignore as AO-managed so
// EnsureWorkspaceGitignore can rewrite its own file idempotently while never
// touching a user- or repo-provided .gitignore at the same path.
const GitignoreSentinel = "# managed by agent-orchestrator: AO hook files stay out of git status"

// EnsureWorkspaceGitignore writes a self-ignoring .gitignore into dir covering
// the named AO-installed files. Hook files land in fresh session worktrees as
// untracked files, and `git worktree remove` (without --force) refuses on ANY
// untracked file — without this ignore, AO's own hook files would make every
// session workspace permanently undeletable. The patterns are anchored to dir
// and name only AO's files, so anything else an agent drops in the same
// directory still counts as dirt and keeps blocking teardown.
//
// A .gitignore at the same path that lacks the sentinel is left untouched and
// the install proceeds: the worktree then simply stays dirty and teardown
// preserves it, which is the safe degradation.
func EnsureWorkspaceGitignore(dir string, names ...string) error {
	path := filepath.Join(dir, ".gitignore")
	existing, err := os.ReadFile(path) //nolint:gosec // path built from caller-owned workspace dir
	if err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("read %s: %w", path, err)
	}
	if err == nil && !strings.Contains(string(existing), GitignoreSentinel) {
		return nil
	}
	var b strings.Builder
	b.WriteString(GitignoreSentinel)
	b.WriteString("\n/.gitignore\n")
	for _, name := range names {
		b.WriteString("/")
		b.WriteString(filepath.ToSlash(name))
		b.WriteString("\n")
	}
	if err := os.MkdirAll(dir, 0o750); err != nil {
		return fmt.Errorf("create %s: %w", dir, err)
	}
	if err := AtomicWriteFile(path, []byte(b.String()), 0o600); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

// AtomicWriteFile writes data to path via a temp file in the same directory
// followed by a rename, so a crash or signal mid-write can't leave a truncated
// or empty file that the agent then fails to parse (silently disabling hooks).
func AtomicWriteFile(path string, data []byte, perm os.FileMode) error {
	tmp, err := os.CreateTemp(filepath.Dir(path), ".ao-tmp-*")
	if err != nil {
		return err
	}
	tmpName := tmp.Name()
	defer func() { _ = os.Remove(tmpName) }() // no-op once renamed
	if _, err := tmp.Write(data); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Chmod(perm); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Sync(); err != nil {
		_ = tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	return os.Rename(tmpName, path)
}
