// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package skills

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

	ociskills "github.com/stacklok/toolhive-core/oci/skills"
	"github.com/stacklok/toolhive/pkg/fileutils"
)

const (
	// MaxTotalExtractSize is the maximum total decompressed size (500MB).
	MaxTotalExtractSize int64 = 500 * 1024 * 1024
	// MaxFileExtractSize is the maximum size per file in the tar archive (100MB).
	// Matches the toolhive-core default.
	MaxFileExtractSize int64 = 100 * 1024 * 1024
	// MaxExtractFileCount is the maximum number of files allowed in an archive.
	MaxExtractFileCount = 1000

	// DirPermissions is the permission mode for created directories.
	DirPermissions os.FileMode = 0750
	// FilePermissionMask strips setuid, setgid, sticky bits and caps at 0644.
	FilePermissionMask os.FileMode = 0644
)

// ExtractResult contains the outcome of an Extract operation.
type ExtractResult struct {
	// SkillDir is the absolute path where the skill was extracted.
	SkillDir string
	// Files is the number of files written.
	Files int
}

// defaultInstaller is the production implementation of Installer.
type defaultInstaller struct{}

// NewInstaller returns a production Installer that delegates to the package-level
// Extract and Remove functions.
func NewInstaller() Installer {
	return &defaultInstaller{}
}

func (*defaultInstaller) Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, error) {
	return Extract(layerData, targetDir, force)
}

func (*defaultInstaller) Remove(skillDir string) error {
	return Remove(skillDir)
}

// Extract decompresses a tar.gz OCI layer and writes files to targetDir.
// If targetDir exists and force is false, an error is returned.
// If force is true, the existing directory is removed before extraction.
func Extract(layerData []byte, targetDir string, force bool) (*ExtractResult, error) {
	// Decompress gzip with total size limit
	tarData, err := ociskills.DecompressWithLimit(layerData, MaxTotalExtractSize)
	if err != nil {
		return nil, fmt.Errorf("decompressing layer: %w", err)
	}

	// Extract tar with per-file size limit (rejects symlinks, hardlinks, path traversal)
	files, err := ociskills.ExtractTarWithLimit(tarData, MaxFileExtractSize)
	if err != nil {
		return nil, fmt.Errorf("extracting tar: %w", err)
	}

	if len(files) > MaxExtractFileCount {
		return nil, fmt.Errorf("archive contains %d files, exceeding limit of %d", len(files), MaxExtractFileCount)
	}

	// Handle existing directory
	if _, statErr := os.Stat(targetDir); statErr == nil {
		if !force {
			return nil, fmt.Errorf("target directory %q already exists; use force to overwrite", targetDir)
		}
		if err := Remove(targetDir); err != nil {
			return nil, fmt.Errorf("removing existing directory: %w", err)
		}
	}

	// Pre-extraction: validate that no existing path components are symlinks.
	// This prevents an attacker from placing a symlink at a parent directory
	// that would cause MkdirAll/writes to follow through to an unintended location.
	if err := ValidatePathNoSymlinks(targetDir); err != nil {
		return nil, fmt.Errorf("target path validation: %w", err)
	}

	if err := os.MkdirAll(targetDir, DirPermissions); err != nil {
		return nil, fmt.Errorf("creating target directory: %w", err)
	}

	if err := writeFiles(files, targetDir); err != nil {
		return nil, err
	}

	// Defense in depth: verify the extracted directory post-extraction
	if err := CheckFilesystem(targetDir); err != nil {
		_ = os.RemoveAll(targetDir) // clean up on verification failure
		return nil, fmt.Errorf("post-extraction verification failed: %w", err)
	}

	return &ExtractResult{
		SkillDir: targetDir,
		Files:    len(files),
	}, nil
}

// writeFiles writes extracted file entries to targetDir with containment checks
// and sanitized permissions.
func writeFiles(files []ociskills.FileEntry, targetDir string) error {
	cleanTarget := filepath.Clean(targetDir)

	for _, f := range files {
		// Sanitize file permissions: strip setuid/setgid/sticky, cap at 0644.
		// Pre-write containment check is handled by WriteContainedFile.
		mode := os.FileMode(f.Mode&0o777) & FilePermissionMask //nolint:gosec // mode is masked to 9 bits before conversion

		if err := fileutils.WriteContainedFile(cleanTarget, f.Path, f.Content, DirPermissions, mode); err != nil {
			return err
		}
	}
	return nil
}

// Remove safely removes a skill directory. Returns nil if the directory does not exist.
func Remove(skillDir string) error {
	if skillDir == "" {
		return fmt.Errorf("skill directory path must not be empty")
	}

	// Resolve to absolute path for safety checks
	absPath, err := filepath.Abs(skillDir)
	if err != nil {
		return fmt.Errorf("resolving absolute path: %w", err)
	}

	// Use Lstat (not Stat) to detect symlinks without following them.
	info, err := os.Lstat(absPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil
		}
		return fmt.Errorf("checking path %q: %w", absPath, err)
	}

	// Refuse to remove if the path itself is a symlink — prevents an attacker
	// from replacing the skill directory with a symlink to trick us into
	// deleting an arbitrary location.
	if info.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("refusing to remove symlink at %q: expected a directory", absPath)
	}

	// Resolve any symlinks in parent components to get the real path for
	// the dangerous-path checks below.
	realPath, err := filepath.EvalSymlinks(absPath)
	if err != nil {
		return fmt.Errorf("resolving symlinks in path: %w", err)
	}

	// Guard against removing dangerous paths (checked against resolved path).
	// filepath.Dir(p) == p is true at filesystem roots on all platforms
	// (e.g. "/" on Unix, "C:\" on Windows).
	homeDir, homeErr := os.UserHomeDir()
	if filepath.Dir(realPath) == realPath {
		return fmt.Errorf("refusing to remove dangerous path %q", realPath)
	}
	if homeErr == nil && realPath == homeDir {
		return fmt.Errorf("refusing to remove dangerous path %q", realPath)
	}
	// If we couldn't determine the home directory, refuse shallow paths as a safety net.
	// Count path depth by splitting on separator (e.g., "/var/home/user" → 4 components).
	if homeErr != nil && pathDepth(realPath) < 4 {
		return fmt.Errorf("refusing to remove shallow path %q (could not determine home directory)", realPath)
	}

	return os.RemoveAll(absPath)
}

// ValidatePathNoSymlinks walks up from the target path checking each existing
// path component for symlinks. This prevents symlink attacks where an attacker
// places a symlink at a parent directory before extraction.
func ValidatePathNoSymlinks(targetDir string) error {
	absTarget, err := filepath.Abs(targetDir)
	if err != nil {
		return fmt.Errorf("resolving absolute path: %w", err)
	}

	// Walk each component from the root down, checking existing segments.
	// Use filepath.VolumeName to determine the root correctly on all platforms
	// (e.g. "/" on Unix, "C:\" on Windows).
	current := func() string {
		if vol := filepath.VolumeName(absTarget); vol != "" {
			return vol + string(os.PathSeparator)
		}
		return string(os.PathSeparator)
	}()
	for _, component := range strings.Split(absTarget, string(os.PathSeparator)) {
		if component == "" {
			continue
		}
		current = filepath.Join(current, component)

		info, err := os.Lstat(current)
		if err != nil {
			// Path doesn't exist yet — remaining components will be created by MkdirAll.
			break
		}
		if info.Mode()&os.ModeSymlink != 0 {
			return fmt.Errorf("symlink found at %q: refusing to extract through symlinks", current)
		}
	}
	return nil
}

// RemoveEmptyParents walks up from dir, removing each directory that is empty,
// stopping at stopAt (which is never removed) or when a non-empty directory is
// encountered. Errors are silently ignored — this is best-effort cleanup.
func RemoveEmptyParents(dir, stopAt string) {
	dir = filepath.Clean(dir)
	stopAt = filepath.Clean(stopAt)
	for dir != stopAt && filepath.Dir(dir) != dir {
		if err := os.Remove(dir); err != nil {
			// Directory is not empty, doesn't exist, or we lack permission — stop.
			return
		}
		dir = filepath.Dir(dir)
	}
}

// pathDepth counts the number of non-empty components in an absolute path.
// For example, "/var/home/user/skills" returns 4.
func pathDepth(absPath string) int {
	count := 0
	for _, part := range strings.Split(absPath, string(os.PathSeparator)) {
		if part != "" {
			count++
		}
	}
	return count
}
