package execenv

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"os"
	"path/filepath"
)

// sidecarManifestFile is the on-disk JSON Prepare writes into envRoot to
// record every file and intermediate directory it created inside WorkDir.
// CleanupSidecars reads it back to roll the workdir to its pre-Prepare
// state. The file lives in envRoot (daemon scratch), never in WorkDir,
// so a local_directory run does not litter the user's repo with the
// bookkeeping file used to undo the litter.
const sidecarManifestFile = ".multica_sidecar_manifest.json"

// errPathPreExists is the sentinel recordWriteFile returns when the
// target path already exists. The manifest contract is that we never
// mutate paths we don't own: a pre-existing file belongs to the user
// (or to stale state from a crashed prior run we cannot safely
// distinguish from intentional user content) and the write must be
// refused so cleanup can be a pure deletion of paths we created.
//
// Callers handle this in one of two ways:
//
//   - For per-skill directories the caller allocates a collision-free
//     alternative slug (see allocateCollisionFreeSkillDir) and retries
//     so the agent still discovers the Multica skill, just under a
//     different directory name.
//   - For Multica-only namespaces (.agent_context/issue_context.md,
//     .multica/project/resources.json) the caller swallows the error
//     and proceeds — the agent's runtime brief already carries every
//     fact that would have appeared in those files, so missing-from-
//     disk is degraded behavior, not failure.
var errPathPreExists = errors.New("execenv: refuse to overwrite pre-existing path")

// sidecarManifest records the filesystem mutations writeContextFiles and
// its callees make inside the agent's WorkDir for a single task. The
// manifest is the second half of the contract that makes local_directory
// runs byte-exactly reversible:
//
//   - Files lists absolute paths of regular files we created. Files are
//     recorded only after recordWriteFile has verified the target did
//     NOT pre-exist; recordWriteFile refuses to overwrite a pre-existing
//     path, so the manifest's existence rule and the write side's
//     refuse-to-clobber rule are the same invariant viewed from two
//     sides.
//   - Dirs lists absolute paths of directories we created, in root-first
//     creation order. Cleanup walks the list in reverse so deepest dirs
//     get tried first; rmdir of a directory the user has populated since
//     (e.g. .claude/skills/my-own-skill alongside our .claude/skills/
//     issue-review) fails ENOTEMPTY and is skipped silently — the
//     user's content is preserved without any per-dir bookkeeping. A
//     directory is recorded only when it did NOT pre-exist for the same
//     reason files are conditional.
//
// The manifest is intentionally minimal: it carries the paths needed to
// reverse our writes and nothing else. It is not a log of every operation
// and is not a substitute for the runtime config marker block, which has
// its own dedicated round-trip mechanism in runtime_config.go (the brief
// is appended to user-owned content rather than written into a new sidecar
// directory).
type sidecarManifest struct {
	Files []string `json:"files,omitempty"`
	Dirs  []string `json:"dirs,omitempty"`
}

// recordMkdirAll behaves like os.MkdirAll(path, perm) but additionally
// records every parent directory it had to create (skipping any that
// already existed) into m so CleanupSidecars can rmdir them later. The
// recorded paths are appended in root-first order; Cleanup iterates in
// reverse so the deepest directory is removed first.
//
// When m is nil this is identical to os.MkdirAll — the Reuse path uses
// the nil mode because Reuse runs on cloud workdirs that the GC loop
// wipes wholesale, so per-file cleanup is irrelevant and tracking the
// dirs would just leave stale manifest bytes around.
func recordMkdirAll(path string, perm os.FileMode, m *sidecarManifest) error {
	if path == "" {
		return os.MkdirAll(path, perm)
	}
	if m == nil {
		return os.MkdirAll(path, perm)
	}
	// Walk leaf-first, collecting ancestors that don't currently exist.
	// We stop at the first existing ancestor (or the filesystem root) so
	// pre-existing user directories are never recorded — Cleanup must
	// not rmdir a path the user owned before this task started.
	var toCreate []string
	cur := filepath.Clean(path)
	for {
		if _, err := os.Lstat(cur); err == nil {
			break
		} else if !errors.Is(err, fs.ErrNotExist) {
			return fmt.Errorf("stat ancestor %s: %w", cur, err)
		}
		toCreate = append(toCreate, cur)
		parent := filepath.Dir(cur)
		if parent == cur || parent == "." {
			break
		}
		cur = parent
	}
	if err := os.MkdirAll(path, perm); err != nil {
		return err
	}
	// Reverse leaf-first → root-first so Cleanup can reverse-iterate
	// to peel directories from the leaves upward.
	for i, j := 0, len(toCreate)-1; i < j; i, j = i+1, j-1 {
		toCreate[i], toCreate[j] = toCreate[j], toCreate[i]
	}
	m.Dirs = append(m.Dirs, toCreate...)
	return nil
}

// recordWriteFile writes data to path with perm and records the path in
// m for later cleanup, but ONLY when path does not already exist. When
// path is occupied — by a regular file, a symlink, a directory, or any
// other filesystem entry — the function returns errPathPreExists
// without touching the path. The user's bytes (or pre-existing entry
// type) are preserved exactly.
//
// This is the invariant the manifest design rests on: cleanup is a
// pure deletion of paths we created, never a restore. Overwriting a
// pre-existing path and then refusing to delete it on cleanup (the
// pre-fix behavior) destroys user data twice — once at write time and
// once by leaving the corrupted bytes in place at exit. Refusing to
// overwrite removes both halves of that failure mode.
//
// When m is nil this collapses to a plain os.WriteFile — the Reuse
// path uses the nil mode because Reuse runs on cloud workdirs that
// the GC loop wipes wholesale, so per-file collision avoidance is
// irrelevant.
func recordWriteFile(path string, data []byte, perm os.FileMode, m *sidecarManifest) error {
	if m == nil {
		return os.WriteFile(path, data, perm)
	}
	_, statErr := os.Lstat(path)
	if statErr == nil {
		// Any existing entry — regular file, symlink, directory —
		// is a collision. Refuse to touch it.
		return fmt.Errorf("%w: %s", errPathPreExists, path)
	}
	if !errors.Is(statErr, fs.ErrNotExist) {
		return fmt.Errorf("stat target %s: %w", path, statErr)
	}
	if err := os.WriteFile(path, data, perm); err != nil {
		return err
	}
	m.Files = append(m.Files, path)
	return nil
}

// allocateCollisionFreeSkillDir picks a directory under skillsParent
// whose path does NOT currently exist, so writeSkillFiles can lay
// down a Multica skill without colliding with a user-installed skill
// of the same slug. The first attempt is always the natural baseSlug
// — that's the path provider-native discovery already knows. On
// collision we append `-multica`, then `-multica-2`, `-multica-3`,
// … until a free slot is found. The chosen slug is returned alongside
// the absolute path so callers can use it in frontmatter and brief
// listings.
//
// The collision-free fallback name is still a sibling under the same
// skillsParent, so provider-native discovery still picks the skill up
// (each subdir under .claude/skills/ etc. is scanned independently).
// The user's directory at baseSlug is left bit-for-bit intact.
//
// The probe is bounded to a small ceiling — a user with thousands of
// collisions on the same slug indicates an upstream bug, not a
// realistic state. Returning an error in that case forces the caller
// to surface the problem instead of looping forever.
func allocateCollisionFreeSkillDir(skillsParent, baseSlug string) (slug, dir string, err error) {
	const maxAttempts = 64
	for i := 0; i < maxAttempts; i++ {
		var candidate string
		switch {
		case i == 0:
			candidate = baseSlug
		case i == 1:
			candidate = baseSlug + "-multica"
		default:
			candidate = fmt.Sprintf("%s-multica-%d", baseSlug, i)
		}
		path := filepath.Join(skillsParent, candidate)
		if _, statErr := os.Lstat(path); statErr != nil {
			if errors.Is(statErr, fs.ErrNotExist) {
				return candidate, path, nil
			}
			return "", "", fmt.Errorf("stat candidate %s: %w", path, statErr)
		}
	}
	return "", "", fmt.Errorf("allocate collision-free skill dir under %s: exhausted %d attempts for base %q", skillsParent, maxAttempts, baseSlug)
}

// writeSidecarManifest persists m to {envRoot}/{sidecarManifestFile}.
// Empty manifests are still written so a later Cleanup that finds the
// file knows tracking was attempted (vs. an old build that predates this
// mechanism, where the file is absent and Cleanup must no-op). Failures
// are returned to the caller; the caller treats them as non-fatal because
// a missed manifest only degrades local_directory cleanup, not task
// execution.
func writeSidecarManifest(envRoot string, m *sidecarManifest) error {
	if envRoot == "" {
		return nil
	}
	if m == nil {
		m = &sidecarManifest{}
	}
	data, err := json.Marshal(m)
	if err != nil {
		return fmt.Errorf("marshal sidecar manifest: %w", err)
	}
	return os.WriteFile(filepath.Join(envRoot, sidecarManifestFile), data, 0o644)
}

// CleanupSidecars rolls the user's workdir back to its pre-Prepare
// state by removing every file the manifest at envRoot records and
// then rmdir-ing every directory it records, deepest first.
//
// Two failure modes the function deliberately swallows:
//
//   - ENOENT on a recorded path. The file or directory was already
//     gone — either the user removed it during the task, or a prior
//     Cleanup run on the same envRoot already cleared it. Either
//     way there is nothing left for this call to do.
//   - Non-empty directory on rmdir. The user has populated a
//     directory we created (added a sibling file under .claude/
//     skills/, for example) and rmdir-ing would destroy that
//     content. We detect this by re-reading the directory after
//     rmdir fails: a non-empty listing means "user owns this — stop
//     here." This is the must-fix from PR #3444 review — the
//     previous version swallowed ANY non-ENOENT rmdir error as
//     "non-empty," which silently dropped real I/O failures
//     (EACCES, EPERM, EBUSY) and made cleanup look successful when
//     it wasn't.
//
// All other errors — ReadFile failure, JSON parse failure, real
// EACCES/EPERM/EIO during file deletion, real EACCES/EPERM/EIO
// during dir removal — are captured into firstErr and surfaced to
// the caller. Cleanup still continues for the remaining manifest
// entries so a single bad path does not strand the rest of the
// rollback.
//
// The function is a no-op when:
//   - envRoot is empty (no daemon scratch for this task),
//   - the manifest file is missing (older build, or Prepare did not run).
//
// Pair this with CleanupRuntimeConfig on the local_directory cleanup
// path: that function handles the runtime brief inside CLAUDE.md /
// AGENTS.md / GEMINI.md, this one handles the sidecar tree
// (.agent_context/, .multica/, .claude/skills/, .github/skills/,
// .opencode/skills/, skills/, .pi/skills/, .cursor/skills/,
// .kimi/skills/, .kiro/skills/, .agents/skills/, fallback
// .agent_context/skills/). The two together restore the workdir to
// byte-exact pre-task state.
func CleanupSidecars(envRoot string) error {
	if envRoot == "" {
		return nil
	}
	manifestPath := filepath.Join(envRoot, sidecarManifestFile)
	data, err := os.ReadFile(manifestPath)
	if errors.Is(err, fs.ErrNotExist) {
		return nil
	}
	if err != nil {
		return fmt.Errorf("read sidecar manifest %s: %w", manifestPath, err)
	}
	var m sidecarManifest
	if err := json.Unmarshal(data, &m); err != nil {
		return fmt.Errorf("parse sidecar manifest %s: %w", manifestPath, err)
	}

	var firstErr error
	captureErr := func(err error) {
		if firstErr == nil {
			firstErr = err
		}
	}

	for _, f := range m.Files {
		if err := os.Remove(f); err != nil && !errors.Is(err, fs.ErrNotExist) {
			captureErr(fmt.Errorf("remove %s: %w", f, err))
		}
	}

	// Reverse iterate so the deepest directory is tried first. When
	// rmdir fails we re-read the directory to tell ENOTEMPTY (user
	// content present — skip silently) apart from real I/O errors
	// (permission denied, busy, etc. — capture and surface).
	for i := len(m.Dirs) - 1; i >= 0; i-- {
		d := m.Dirs[i]
		err := os.Remove(d)
		if err == nil || errors.Is(err, fs.ErrNotExist) {
			continue
		}
		hasEntries, ok := dirHasEntries(d)
		switch {
		case !ok:
			// ReadDir also failed — we can't tell ENOTEMPTY apart
			// from a real I/O error. Surface the ORIGINAL rmdir
			// error (not the ReadDir failure) so the operator sees
			// the actual cleanup blocker; the ReadDir branch is
			// just diagnostic plumbing and would distract from the
			// root cause. Silently skipping here was the v1 bug:
			// it hid EACCES on locked directories behind a phantom
			// "directory non-empty" assumption.
			captureErr(fmt.Errorf("rmdir %s: %w", d, err))
		case hasEntries:
			// User has populated this dir since Prepare ran. Leave
			// it in place without surfacing the rmdir error — the
			// whole point of the manifest design is to preserve
			// user content under directories we created.
		default:
			// Empty directory but rmdir still failed → real I/O
			// error (EACCES, EPERM, EBUSY, EIO, or a directory we
			// mistakenly recorded that we don't actually own).
			// Surface it so the caller can log a warning and an
			// operator can investigate.
			captureErr(fmt.Errorf("rmdir %s: %w", d, err))
		}
	}

	if err := os.Remove(manifestPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
		captureErr(fmt.Errorf("remove manifest %s: %w", manifestPath, err))
	}

	return firstErr
}

// dirHasEntries inspects dir and reports whether it currently contains
// any entries. The second return value distinguishes three states
// CleanupSidecars must handle separately:
//
//   - (false, true) — dir exists and is empty, OR dir disappeared
//     between the failed rmdir and our readdir (the race collapses
//     into "empty" so cleanup keeps moving). When paired with a
//     non-ENOENT rmdir failure in CleanupSidecars this is the
//     "empty + rmdir refused" branch — a real I/O error that gets
//     surfaced.
//   - (true, true) — dir has user content. When paired with a rmdir
//     failure this is the intended ENOTEMPTY branch — skip silently
//     so the user's content is preserved.
//   - (_, false) — readdir failed with a real I/O error (EACCES on a
//     chmod'd dir, ENOTDIR on a recorded path that isn't actually a
//     dir, EIO on a hardware fault, etc.). The caller cannot tell
//     ENOTEMPTY from a real failure and MUST surface the original
//     rmdir error instead of silently skipping. The v1 of this
//     helper returned `true` here, which made CleanupSidecars treat
//     every readdir failure as "user content present" and hid the
//     underlying rmdir error.
func dirHasEntries(dir string) (hasEntries bool, ok bool) {
	entries, err := os.ReadDir(dir)
	if err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			return false, true
		}
		return false, false
	}
	return len(entries) > 0, true
}
