// Package workspace provides filesystem operations for workspaces.
// It operates on the PVC filesystem and keeps the DB metadata index in sync
// via the WorkspaceStore.
package workspace

import (
	"bufio"
	"context"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"log"
	"mime"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"

	"chat-switchboard/models"
	"chat-switchboard/store"
)

// ── Configuration ───────────────────────────

const (
	// DefaultMaxBytes is the default workspace quota (500 MB).
	DefaultMaxBytes int64 = 500 * 1024 * 1024

	// MaxSingleFileBytes is the max size for a single file write (100 MB).
	MaxSingleFileBytes int64 = 100 * 1024 * 1024

	// filesSubdir is the subdirectory within a workspace root that holds user files.
	// Keeps metadata (.workspace.json, future .git/) out of the user's namespace.
	filesSubdir = "files"

	// metadataFile is the workspace metadata file stored in the workspace root.
	metadataFile = ".workspace.json"
)

// ── FS ──────────────────────────────────────

// FS provides filesystem operations for workspaces.
// All file paths are relative to the workspace's files/ subdirectory.
// The DB metadata index is updated on every mutating operation.
type FS struct {
	basePath string // e.g. /data/storage/workspaces
	store    store.WorkspaceStore
	indexer  *Indexer // optional — set via SetIndexer for v0.21.2 indexing
}

// NewFS creates a workspace filesystem manager.
func NewFS(basePath string, ws store.WorkspaceStore) *FS {
	return &FS{basePath: basePath, store: ws}
}

// SetIndexer attaches the workspace indexer for background file indexing.
// Must be called after NewIndexer is created (circular dependency break).
func (fs *FS) SetIndexer(idx *Indexer) {
	fs.indexer = idx
}

// Init ensures the base workspaces directory exists.
func (fs *FS) Init() error {
	return os.MkdirAll(fs.basePath, 0750)
}

// ── Path Helpers ────────────────────────────

// filesDir returns the absolute path to a workspace's file tree root.
func (fs *FS) filesDir(w *models.Workspace) string {
	return filepath.Join(fs.basePath, w.ID, filesSubdir)
}

// absPath resolves a relative workspace path to an absolute filesystem path.
// Returns an error if the path escapes the workspace root (traversal attack).
func (fs *FS) absPath(w *models.Workspace, relPath string) (string, error) {
	orig := strings.TrimSpace(relPath)
	relPath = cleanPath(relPath)
	if relPath == "" {
		if orig != "" && orig != "." && orig != "./" {
			// Non-empty input was sanitized to empty — traversal attempt
			return "", fmt.Errorf("workspace: path traversal detected: %s", orig)
		}
		return fs.filesDir(w), nil
	}

	root := fs.filesDir(w)
	abs := filepath.Join(root, relPath)

	// Resolve symlinks and ensure we're still under root
	absResolved, err := filepath.Abs(abs)
	if err != nil {
		return "", fmt.Errorf("workspace: invalid path: %w", err)
	}
	rootResolved, err := filepath.Abs(root)
	if err != nil {
		return "", fmt.Errorf("workspace: invalid root: %w", err)
	}

	if !strings.HasPrefix(absResolved, rootResolved+string(filepath.Separator)) && absResolved != rootResolved {
		return "", fmt.Errorf("workspace: path traversal detected: %s", relPath)
	}

	return abs, nil
}

// cleanPath normalizes a relative path: removes leading slashes, cleans ./ and ../
func cleanPath(p string) string {
	p = strings.TrimSpace(p)
	p = path.Clean(p)
	p = strings.TrimPrefix(p, "/")
	p = strings.TrimPrefix(p, "./")

	// Reject paths that resolve outside root
	if p == ".." || strings.HasPrefix(p, "../") || strings.Contains(p, "/../") {
		return ""
	}
	if p == "." {
		return ""
	}
	return p
}

// ── Workspace Lifecycle ─────────────────────

// CreateDir creates the workspace directory structure on disk.
// Call after the DB row is created.
func (fs *FS) CreateDir(w *models.Workspace) error {
	filesPath := fs.filesDir(w)
	return os.MkdirAll(filesPath, 0750)
}

// Destroy removes the entire workspace directory from disk.
// Call after marking the workspace as "deleting" in DB.
func (fs *FS) Destroy(ctx context.Context, w *models.Workspace) error {
	wsDir := filepath.Join(fs.basePath, w.ID)

	// Remove all file index entries first
	if err := fs.store.DeleteAllFiles(ctx, w.ID); err != nil {
		log.Printf("workspace: failed to delete file index for %s: %v", w.ID, err)
	}

	return os.RemoveAll(wsDir)
}

// ── File Operations ─────────────────────────

// ReadFile returns a reader for the file at relPath.
// Caller must close the returned ReadCloser.
func (fs *FS) ReadFile(ctx context.Context, w *models.Workspace, relPath string) (io.ReadCloser, int64, error) {
	abs, err := fs.absPath(w, relPath)
	if err != nil {
		return nil, 0, err
	}

	info, err := os.Stat(abs)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, 0, fmt.Errorf("workspace: file not found: %s", relPath)
		}
		return nil, 0, err
	}
	if info.IsDir() {
		return nil, 0, fmt.Errorf("workspace: cannot read directory: %s", relPath)
	}

	f, err := os.Open(abs)
	if err != nil {
		return nil, 0, err
	}
	return f, info.Size(), nil
}

// WriteFile creates or overwrites a file at relPath.
// Creates parent directories as needed. Updates the DB metadata index.
func (fs *FS) WriteFile(ctx context.Context, w *models.Workspace, relPath string, r io.Reader, size int64) error {
	relPath = cleanPath(relPath)
	if relPath == "" {
		return fmt.Errorf("workspace: empty file path")
	}

	abs, err := fs.absPath(w, relPath)
	if err != nil {
		return err
	}

	// Reject symlinks at destination
	if info, err := os.Lstat(abs); err == nil && info.Mode()&os.ModeSymlink != 0 {
		return fmt.Errorf("workspace: refusing to overwrite symlink: %s", relPath)
	}

	// Check existing sha256 for content-addressed skip (v0.21.2)
	var oldSHA256 string
	if existing, err := fs.store.GetFile(ctx, w.ID, relPath); err == nil {
		oldSHA256 = existing.SHA256
	}

	// Ensure parent directory exists
	dir := filepath.Dir(abs)
	if err := os.MkdirAll(dir, 0750); err != nil {
		return fmt.Errorf("workspace: mkdir %s: %w", filepath.Dir(relPath), err)
	}

	// Write to temp file + rename (atomic)
	tmp, err := os.CreateTemp(dir, ".ws-*.tmp")
	if err != nil {
		return fmt.Errorf("workspace: create temp: %w", err)
	}
	tmpName := tmp.Name()
	defer func() {
		tmp.Close()
		os.Remove(tmpName) // cleanup on error; no-op after successful rename
	}()

	// Write content and compute hash simultaneously
	h := sha256.New()
	tee := io.TeeReader(r, h)
	n, err := io.Copy(tmp, tee)
	if err != nil {
		return fmt.Errorf("workspace: write %s: %w", relPath, err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("workspace: close temp: %w", err)
	}

	// Atomic rename
	if err := os.Rename(tmpName, abs); err != nil {
		return fmt.Errorf("workspace: rename %s: %w", relPath, err)
	}

	// Update DB index
	contentType := detectContentType(relPath, abs)
	newHash := hex.EncodeToString(h.Sum(nil))

	f := &models.WorkspaceFile{
		WorkspaceID: w.ID,
		Path:        relPath,
		IsDirectory: false,
		ContentType: contentType,
		SizeBytes:   n,
		SHA256:      newHash,
	}
	if err := fs.store.UpsertFile(ctx, f); err != nil {
		return err
	}

	// Trigger async indexing if content changed (v0.21.2)
	if fs.indexer != nil && newHash != oldSHA256 {
		// Use workspace owner for embedding provider resolution
		var teamID *string
		if w.OwnerType == models.WorkspaceOwnerTeam {
			teamID = &w.OwnerID
		}
		fs.indexer.IndexFile(w, f, w.OwnerID, teamID)
	}

	return nil
}

// DeleteFile removes a file or empty directory at relPath.
func (fs *FS) DeleteFile(ctx context.Context, w *models.Workspace, relPath string, recursive bool) error {
	relPath = cleanPath(relPath)
	if relPath == "" {
		return fmt.Errorf("workspace: cannot delete workspace root")
	}

	abs, err := fs.absPath(w, relPath)
	if err != nil {
		return err
	}

	info, err := os.Stat(abs)
	if err != nil {
		if os.IsNotExist(err) {
			// Clean up DB index anyway
			fs.store.DeleteFile(ctx, w.ID, relPath)
			return nil
		}
		return err
	}

	if info.IsDir() {
		if recursive {
			if err := os.RemoveAll(abs); err != nil {
				return err
			}
			// Remove all files under this prefix from the index
			prefix := relPath
			if !strings.HasSuffix(prefix, "/") {
				prefix += "/"
			}
			fs.store.DeleteFilesByPrefix(ctx, w.ID, prefix)
			return fs.store.DeleteFile(ctx, w.ID, relPath)
		}
		// Non-recursive: only remove empty dirs
		if err := os.Remove(abs); err != nil {
			return fmt.Errorf("workspace: directory not empty (use recursive=true): %s", relPath)
		}
	} else {
		if err := os.Remove(abs); err != nil {
			return err
		}
	}

	return fs.store.DeleteFile(ctx, w.ID, relPath)
}

// Mkdir creates a directory at relPath. Creates parents as needed.
func (fs *FS) Mkdir(ctx context.Context, w *models.Workspace, relPath string) error {
	relPath = cleanPath(relPath)
	if relPath == "" {
		return nil // root always exists
	}

	abs, err := fs.absPath(w, relPath)
	if err != nil {
		return err
	}

	if err := os.MkdirAll(abs, 0750); err != nil {
		return err
	}

	return fs.store.UpsertFile(ctx, &models.WorkspaceFile{
		WorkspaceID: w.ID,
		Path:        relPath,
		IsDirectory: true,
	})
}

// MoveFile renames or moves a file within the workspace.
// Creates destination parent directories as needed.
func (fs *FS) MoveFile(ctx context.Context, w *models.Workspace, srcRel, dstRel string) error {
	srcRel = cleanPath(srcRel)
	dstRel = cleanPath(dstRel)
	if srcRel == "" || dstRel == "" {
		return fmt.Errorf("workspace: source and destination paths required")
	}
	if srcRel == dstRel {
		return nil // no-op
	}

	srcAbs, err := fs.absPath(w, srcRel)
	if err != nil {
		return err
	}
	dstAbs, err := fs.absPath(w, dstRel)
	if err != nil {
		return err
	}

	// Ensure source exists
	srcInfo, err := os.Stat(srcAbs)
	if err != nil {
		return fmt.Errorf("workspace: source not found: %s", srcRel)
	}

	// Ensure destination parent exists
	if err := os.MkdirAll(filepath.Dir(dstAbs), 0750); err != nil {
		return fmt.Errorf("workspace: mkdir for move: %w", err)
	}

	// Rename on disk
	if err := os.Rename(srcAbs, dstAbs); err != nil {
		return fmt.Errorf("workspace: rename %s → %s: %w", srcRel, dstRel, err)
	}

	// Update DB index: delete old, upsert new
	if srcInfo.IsDir() {
		// For directories, delete all files under old prefix and reconcile new prefix.
		// Simple approach: delete old entries, let next reconcile pick up new ones.
		if err := fs.store.DeleteFilesByPrefix(ctx, w.ID, srcRel+"/"); err != nil {
			log.Printf("workspace move: cleanup old prefix %s: %v", srcRel, err)
		}
		fs.store.DeleteFile(ctx, w.ID, srcRel)
		// Upsert new directory entry
		fs.store.UpsertFile(ctx, &models.WorkspaceFile{
			WorkspaceID: w.ID,
			Path:        dstRel,
			IsDirectory: true,
		})
	} else {
		// Read metadata from old entry if available
		oldFile, _ := fs.store.GetFile(ctx, w.ID, srcRel)
		fs.store.DeleteFile(ctx, w.ID, srcRel)

		newFile := &models.WorkspaceFile{
			WorkspaceID: w.ID,
			Path:        dstRel,
			IsDirectory: false,
			SizeBytes:   srcInfo.Size(),
			ContentType: detectContentType(dstRel, dstAbs),
		}
		if oldFile != nil {
			newFile.SHA256 = oldFile.SHA256
		}
		fs.store.UpsertFile(ctx, newFile)
	}

	return nil
}

// Stat returns file metadata without reading content.
func (fs *FS) Stat(ctx context.Context, w *models.Workspace, relPath string) (*models.WorkspaceFile, error) {
	relPath = cleanPath(relPath)

	// Try DB first (fast)
	f, err := fs.store.GetFile(ctx, w.ID, relPath)
	if err == nil {
		return f, nil
	}

	// Fall back to filesystem
	abs, err := fs.absPath(w, relPath)
	if err != nil {
		return nil, err
	}

	info, err := os.Stat(abs)
	if err != nil {
		return nil, fmt.Errorf("workspace: file not found: %s", relPath)
	}

	return &models.WorkspaceFile{
		WorkspaceID: w.ID,
		Path:        relPath,
		IsDirectory: info.IsDir(),
		SizeBytes:   info.Size(),
		ContentType: detectContentType(relPath, abs),
	}, nil
}

// ListDir returns files at the given prefix.
func (fs *FS) ListDir(ctx context.Context, w *models.Workspace, prefix string, recursive bool) ([]models.WorkspaceFile, error) {
	prefix = cleanPath(prefix)
	if prefix != "" && !strings.HasSuffix(prefix, "/") {
		prefix += "/"
	}
	return fs.store.ListFiles(ctx, w.ID, prefix, recursive)
}

// Tree returns all files in the workspace (recursive from root).
func (fs *FS) Tree(ctx context.Context, w *models.Workspace) ([]models.WorkspaceFile, error) {
	return fs.store.ListFiles(ctx, w.ID, "", true)
}

// ── Reconcile ───────────────────────────────

// Reconcile walks the filesystem and syncs the DB index.
// Adds missing entries, removes stale entries, updates sizes/hashes.
func (fs *FS) Reconcile(ctx context.Context, w *models.Workspace) (added, removed, updated int, err error) {
	root := fs.filesDir(w)

	// v0.27.0: Load .gitignore patterns from workspace root
	ignorePatterns := loadGitignore(root)

	// Walk FS and collect all paths
	fsPaths := make(map[string]os.FileInfo)
	err = filepath.Walk(root, func(abs string, info os.FileInfo, walkErr error) error {
		if walkErr != nil {
			return walkErr
		}
		rel, err := filepath.Rel(root, abs)
		if err != nil {
			return err
		}
		if rel == "." {
			return nil
		}
		// Normalize to forward slashes
		rel = filepath.ToSlash(rel)

		// v0.27.0: Skip gitignored paths
		if matchesGitignore(rel, info.IsDir(), ignorePatterns) {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		fsPaths[rel] = info
		return nil
	})
	if err != nil && !os.IsNotExist(err) {
		return 0, 0, 0, fmt.Errorf("workspace: reconcile walk: %w", err)
	}

	// Get all DB entries
	dbFiles, err := fs.store.ListFiles(ctx, w.ID, "", true)
	if err != nil {
		return 0, 0, 0, fmt.Errorf("workspace: reconcile list: %w", err)
	}

	dbPaths := make(map[string]*models.WorkspaceFile, len(dbFiles))
	for i := range dbFiles {
		dbPaths[dbFiles[i].Path] = &dbFiles[i]
	}

	// Add missing FS entries to DB
	for relPath, info := range fsPaths {
		if _, exists := dbPaths[relPath]; !exists {
			abs := filepath.Join(root, relPath)
			f := &models.WorkspaceFile{
				WorkspaceID: w.ID,
				Path:        relPath,
				IsDirectory: info.IsDir(),
				SizeBytes:   info.Size(),
				ContentType: detectContentType(relPath, abs),
			}
			if !info.IsDir() {
				if hash, hashErr := hashFile(abs); hashErr == nil {
					f.SHA256 = hash
				}
			}
			if upsertErr := fs.store.UpsertFile(ctx, f); upsertErr == nil {
				added++
			}
		}
	}

	// Remove DB entries not on FS
	for dbPath := range dbPaths {
		if _, exists := fsPaths[dbPath]; !exists {
			if delErr := fs.store.DeleteFile(ctx, w.ID, dbPath); delErr == nil {
				removed++
			}
		}
	}

	log.Printf("workspace: reconcile %s: +%d -%d ~%d", w.ID, added, removed, updated)
	return added, removed, updated, nil
}

// ── Content Type Detection ──────────────────

// detectContentType determines the MIME type from extension first,
// then falls back to http.DetectContentType on the first 512 bytes.
func detectContentType(relPath, absPath string) string {
	// Extension-based detection first (covers source code)
	ext := strings.ToLower(filepath.Ext(relPath))
	if ct := mimeByExtension(ext); ct != "" {
		return ct
	}

	// Sniff the first 512 bytes
	f, err := os.Open(absPath)
	if err != nil {
		return "application/octet-stream"
	}
	defer f.Close()

	buf := make([]byte, 512)
	n, _ := f.Read(buf)
	if n == 0 {
		return "application/octet-stream"
	}
	return http.DetectContentType(buf[:n])
}

// mimeByExtension returns MIME type for common extensions.
// mime.TypeByExtension misses many source code types.
func mimeByExtension(ext string) string {
	// Source code extensions that mime.TypeByExtension doesn't know about
	codeTypes := map[string]string{
		".go":         "text/x-go",
		".rs":         "text/x-rust",
		".py":         "text/x-python",
		".rb":         "text/x-ruby",
		".java":       "text/x-java",
		".kt":         "text/x-kotlin",
		".swift":      "text/x-swift",
		".c":          "text/x-c",
		".cpp":        "text/x-c++",
		".h":          "text/x-c",
		".hpp":        "text/x-c++",
		".ts":         "text/typescript",
		".tsx":        "text/typescript",
		".jsx":        "text/jsx",
		".vue":        "text/x-vue",
		".svelte":     "text/x-svelte",
		".scala":      "text/x-scala",
		".pl":         "text/x-perl",
		".pm":         "text/x-perl",
		".lua":        "text/x-lua",
		".zig":        "text/x-zig",
		".nim":        "text/x-nim",
		".ex":         "text/x-elixir",
		".exs":        "text/x-elixir",
		".sh":         "text/x-shellscript",
		".bash":       "text/x-shellscript",
		".zsh":        "text/x-shellscript",
		".fish":       "text/x-shellscript",
		".sql":        "text/x-sql",
		".tf":         "text/x-terraform",
		".hcl":        "text/x-hcl",
		".proto":      "text/x-protobuf",
		".graphql":    "text/x-graphql",
		".toml":       "application/toml",
		".yaml":       "text/yaml",
		".yml":        "text/yaml",
		".dockerfile": "text/x-dockerfile",
		".mod":        "text/x-go-mod",
		".sum":        "text/x-go-sum",
		".lock":       "text/plain",
		".gitignore":  "text/plain",
		".env":        "text/plain",
		".editorconfig": "text/plain",
	}

	if ct, ok := codeTypes[ext]; ok {
		return ct
	}

	// Fall back to standard library
	if ct := mime.TypeByExtension(ext); ct != "" {
		return ct
	}

	return ""
}

// ── Hashing ─────────────────────────────────

// hashFile computes SHA256 of a file.
func hashFile(absPath string) (string, error) {
	f, err := os.Open(absPath)
	if err != nil {
		return "", err
	}
	defer f.Close()

	h := sha256.New()
	if _, err := io.Copy(h, f); err != nil {
		return "", err
	}
	return hex.EncodeToString(h.Sum(nil)), nil
}

// ── Gitignore ───────────────────────────────
// v0.27.0: Simple .gitignore parser for workspace indexing.
// Supports glob patterns, directory-only patterns (trailing /),
// negation (!), and comments (#). Does not support ** (double-star).

type gitignorePattern struct {
	pattern  string
	negate   bool
	dirOnly  bool
}

// loadGitignore reads .gitignore from the workspace root.
// Returns nil if the file doesn't exist.
func loadGitignore(root string) []gitignorePattern {
	f, err := os.Open(filepath.Join(root, ".gitignore"))
	if err != nil {
		return nil
	}
	defer f.Close()

	var patterns []gitignorePattern
	scanner := bufio.NewScanner(f)
	for scanner.Scan() {
		line := strings.TrimSpace(scanner.Text())
		if line == "" || line[0] == '#' {
			continue
		}
		p := gitignorePattern{}
		if line[0] == '!' {
			p.negate = true
			line = line[1:]
		}
		if strings.HasSuffix(line, "/") {
			p.dirOnly = true
			line = strings.TrimSuffix(line, "/")
		}
		p.pattern = line
		patterns = append(patterns, p)
	}

	// Always ignore .git directory
	patterns = append([]gitignorePattern{{pattern: ".git", dirOnly: true}}, patterns...)
	return patterns
}

// matchesGitignore checks if a relative path matches any gitignore pattern.
// Last matching pattern wins (same as git semantics).
func matchesGitignore(relPath string, isDir bool, patterns []gitignorePattern) bool {
	if len(patterns) == 0 {
		return false
	}
	ignored := false
	basename := path.Base(relPath)
	for _, p := range patterns {
		if p.dirOnly && !isDir {
			continue
		}
		// Match against basename or full relative path
		matched := false
		if strings.Contains(p.pattern, "/") {
			// Pattern with slash — match against full path
			matched, _ = filepath.Match(p.pattern, relPath)
		} else {
			// Pattern without slash — match against basename
			matched, _ = filepath.Match(p.pattern, basename)
			if !matched {
				// Also try against full path for prefix directory matches
				matched, _ = filepath.Match(p.pattern, relPath)
			}
		}
		if matched {
			ignored = !p.negate
		}
	}
	return ignored
}
