package main

import (
	"bytes"
	"fmt"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"strings"
)

// handleMove moves the folder at srcFsPath (wiki URL srcURL) to dstURL. When
// updateLinks is true it also rewrites every [[...]] wiki link across the
// tree that targets the old path or any descendant; rewritten files are held
// in memory for rollback.
//
// If the destination folder already exists it is normally a hard conflict.
// The one exception is a merge: when the source carries a page (index.md) and
// the destination folder has none, the source's contents can fill the empty
// container without clobbering anything. That merge only proceeds when merge
// is true; otherwise the client is told a merge is available so it can ask the
// user to confirm.
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, updateLinks, merge bool) {
	oldPath := normalizeMovePath(srcURL)
	if oldPath == "/" {
		http.Error(w, "cannot move wiki root", http.StatusBadRequest)
		return
	}

	newPath, err := validateAndNormalizeNewPath(dstURL)
	if err != nil {
		http.Error(w, "invalid destination: "+err.Error(), http.StatusBadRequest)
		return
	}
	if newPath == oldPath {
		http.Error(w, "destination equals source", http.StatusBadRequest)
		return
	}
	if strings.HasPrefix(newPath, oldPath+"/") {
		http.Error(w, "destination is inside source", http.StatusBadRequest)
		return
	}

	if info, err := os.Stat(srcFsPath); err != nil || !info.IsDir() {
		http.NotFound(w, r)
		return
	}
	dstFsPath := filepath.Join(h.root, filepath.FromSlash(strings.TrimPrefix(newPath, "/")))

	// Decide between a plain rename and a merge into an existing destination.
	merging := false
	if dstInfo, err := os.Stat(dstFsPath); err == nil {
		if !canMergeMove(srcFsPath, dstFsPath, dstInfo) {
			http.Error(w, "destination already exists", http.StatusConflict)
			return
		}
		if !merge {
			// Ask the client to confirm the merge. The header lets it tell
			// this apart from an ordinary conflict.
			w.Header().Set("X-Merge-Available", "1")
			http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
			return
		}
		conflict, err := firstMergeConflict(srcFsPath, dstFsPath)
		if err != nil {
			http.Error(w, "merge check failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
		if conflict != "" {
			http.Error(w, "cannot merge: "+conflict+" exists in both folders", http.StatusConflict)
			return
		}
		merging = true
	}

	// Phase 1: optionally walk the tree and rewrite every index.md that
	// references the moved path. Keep the pre-rewrite bytes in memory so we
	// can revert on failure. The walker only reads directory listings and
	// files literally named index.md; hidden directories are pruned. A cheap
	// substring check skips parsing files that cannot contain a relevant
	// link.
	rewritten := map[string][]byte{}
	if updateLinks {
		needle := []byte("[[" + oldPath)
		walkErr := walkIndexFiles(h.root, func(fsPath string) error {
			orig, err := os.ReadFile(fsPath)
			if err != nil {
				return err
			}
			if !bytes.Contains(orig, needle) {
				return nil
			}
			updated, changed := rewriteWikiLinks(orig, oldPath, newPath)
			if !changed {
				return nil
			}
			if err := writeFileAtomic(fsPath, updated, 0644); err != nil {
				return fmt.Errorf("write %s: %w", fsPath, err)
			}
			rewritten[fsPath] = orig
			return nil
		})
		if walkErr != nil {
			rollbackRewrites(rewritten)
			http.Error(w, "rewrite failed: "+walkErr.Error(), http.StatusInternalServerError)
			return
		}
	}

	// Phase 2: create intermediate parent folders for the destination.
	if parent := filepath.Dir(dstFsPath); parent != "" {
		if err := os.MkdirAll(parent, 0755); err != nil {
			rollbackRewrites(rewritten)
			http.Error(w, "mkdir failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
	}

	// Phase 3: move the source into place. A plain move renames the whole
	// folder; a merge moves the source's entries into the existing
	// destination and drops the emptied source.
	if merging {
		if err := mergeFolder(srcFsPath, dstFsPath); err != nil {
			rollbackRewrites(rewritten)
			http.Error(w, "merge failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
		folderIndexMergeSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
	} else {
		if err := os.Rename(srcFsPath, dstFsPath); err != nil {
			rollbackRewrites(rewritten)
			http.Error(w, "rename failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
		folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
	}

	http.Redirect(w, r, wikiTargetHref(newPath), http.StatusSeeOther)
}

// canMergeMove reports whether moving onto an existing destination should
// merge rather than conflict. Merging is allowed only when the destination is
// a folder with no page of its own (no index.md) and the source has one — so
// the source's page fills the empty destination without overwriting content.
func canMergeMove(srcFsPath, dstFsPath string, dstInfo os.FileInfo) bool {
	if !dstInfo.IsDir() {
		return false
	}
	return hasIndexFile(srcFsPath) && !hasIndexFile(dstFsPath)
}

// hasIndexFile reports whether dir contains a regular index.md.
func hasIndexFile(dir string) bool {
	info, err := os.Stat(filepath.Join(dir, "index.md"))
	return err == nil && info.Mode().IsRegular()
}

// firstMergeConflict returns the name of the first source entry that already
// exists in the destination, or "" when the merge can proceed without
// overwriting anything. index.md cannot collide here: canMergeMove already
// established the destination has none.
func firstMergeConflict(srcFsPath, dstFsPath string) (string, error) {
	entries, err := os.ReadDir(srcFsPath)
	if err != nil {
		return "", err
	}
	for _, e := range entries {
		_, err := os.Lstat(filepath.Join(dstFsPath, e.Name()))
		if err == nil {
			return e.Name(), nil
		}
		if !os.IsNotExist(err) {
			return "", err
		}
	}
	return "", nil
}

// mergeFolder moves every entry from srcFsPath into dstFsPath, then removes
// the now-empty source. Callers must run firstMergeConflict beforehand so no
// rename overwrites an existing destination entry.
func mergeFolder(srcFsPath, dstFsPath string) error {
	entries, err := os.ReadDir(srcFsPath)
	if err != nil {
		return err
	}
	for _, e := range entries {
		from := filepath.Join(srcFsPath, e.Name())
		to := filepath.Join(dstFsPath, e.Name())
		if err := os.Rename(from, to); err != nil {
			return fmt.Errorf("move %s: %w", e.Name(), err)
		}
	}
	return os.Remove(srcFsPath)
}

// handleDelete removes the folder at fsPath (URL urlPath) and redirects to
// the parent. Refuses to touch the wiki root.
func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
	if normalizeMovePath(urlPath) == "/" {
		http.Error(w, "cannot delete wiki root", http.StatusBadRequest)
		return
	}
	if info, err := os.Stat(fsPath); err != nil || !info.IsDir() {
		http.NotFound(w, r)
		return
	}
	if err := os.RemoveAll(fsPath); err != nil {
		http.Error(w, "delete failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
	http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
}

// normalizeMovePath returns the absolute path with any trailing slash removed,
// except for the wiki root which is always "/".
func normalizeMovePath(p string) string {
	if p == "" || p == "/" {
		return "/"
	}
	return "/" + strings.Trim(p, "/")
}

// validateAndNormalizeNewPath returns the cleaned absolute path or an error
// describing why the input was rejected. Empty/root paths, relative paths,
// and paths with bad segments are all invalid.
func validateAndNormalizeNewPath(raw string) (string, error) {
	if !strings.HasPrefix(raw, "/") {
		return "", fmt.Errorf("must start with /")
	}
	trimmed := strings.Trim(raw, "/")
	if trimmed == "" {
		return "", fmt.Errorf("cannot target the wiki root")
	}
	for _, seg := range strings.Split(trimmed, "/") {
		if seg == "" {
			return "", fmt.Errorf("empty segment")
		}
		if seg == "." || seg == ".." {
			return "", fmt.Errorf("segment %q is not allowed", seg)
		}
		if strings.ContainsAny(seg, "\\\x00") {
			return "", fmt.Errorf("segment contains an invalid character")
		}
	}
	return "/" + trimmed, nil
}

// rewriteWikiLinks returns (newContent, changed). Any [[target]] or
// [[target::display]] whose target equals oldPath or begins with oldPath+"/"
// has its target rewritten to the corresponding position under newPath.
func rewriteWikiLinks(content []byte, oldPath, newPath string) ([]byte, bool) {
	changed := false
	out := wikiLinkPattern.ReplaceAllFunc(content, func(match []byte) []byte {
		parts := wikiLinkPattern.FindSubmatch(match)
		if parts == nil {
			return match
		}
		target := strings.TrimSpace(string(parts[1]))
		normTarget := normalizeMovePath(target)
		var newTarget string
		switch {
		case normTarget == oldPath:
			newTarget = newPath
		case strings.HasPrefix(normTarget, oldPath+"/"):
			newTarget = newPath + strings.TrimPrefix(normTarget, oldPath)
		default:
			return match
		}
		changed = true
		suffix := ""
		if len(parts[2]) > 0 {
			suffix = "::" + string(parts[2])
		}
		return []byte("[[" + newTarget + suffix + "]]")
	})
	return out, changed
}

// rollbackRewrites restores the given files to their pre-rewrite contents.
// Errors are logged; best-effort since we're already in a failure path.
func rollbackRewrites(rewritten map[string][]byte) {
	for path, orig := range rewritten {
		if err := writeFileAtomic(path, orig, 0644); err != nil {
			log.Printf("rollback %s: %v", path, err)
		}
	}
}

// walkIndexFiles visits every `index.md` under root, skipping hidden
// directories (names beginning with `.`). Unlike filepath.WalkDir this does
// not stat each regular file — on spinning disks that saves the bulk of the
// traversal cost when folders contain many non-page files (photos, archives).
func walkIndexFiles(root string, visit func(fsPath string) error) error {
	entries, err := os.ReadDir(root)
	if err != nil {
		return err
	}
	for _, e := range entries {
		name := e.Name()
		if strings.HasPrefix(name, ".") {
			continue
		}
		full := filepath.Join(root, name)
		if e.IsDir() {
			if err := walkIndexFiles(full, visit); err != nil {
				return err
			}
			continue
		}
		if name == "index.md" {
			if err := visit(full); err != nil {
				return err
			}
		}
	}
	return nil
}

// writeFileAtomic writes data to a temp file in the same directory as path
// and renames it into place so readers never observe a partial file.
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, ".tmp-*")
	if err != nil {
		return err
	}
	tmpPath := tmp.Name()
	cleanup := true
	defer func() {
		if cleanup {
			_ = os.Remove(tmpPath)
		}
	}()
	if _, err := tmp.Write(data); err != nil {
		tmp.Close()
		return err
	}
	if err := tmp.Close(); err != nil {
		return err
	}
	if err := os.Chmod(tmpPath, perm); err != nil {
		return err
	}
	if err := os.Rename(tmpPath, path); err != nil {
		return err
	}
	cleanup = false
	return nil
}
