package zddc

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

// ProjectRootFolders are the canonical lowercase folder names that may
// appear directly under a project root. The server resolves them
// case-insensitively on disk: a manually-created Working/ is reused
// rather than shadowed by a new working/.
//
//   - "archive"    — formal record of issued/received transmittals,
//                    organised by counterparty (and ourselves)
//   - "working"    — user-owned drafting workspace
//   - "staging"    — outbound-transmittal preparation
//   - "reviewing"  — purely virtual cross-reference of in-progress
//                    review responses (never written to disk)
var ProjectRootFolders = []string{"archive", "working", "staging", "reviewing"}

// PartyFolders are the canonical lowercase folder names that may appear
// directly under archive/<party>/, where <party> is a counterparty or
// the self-folder (we treat ourselves like any other third party).
//
//   - "mdl"        — yaml-per-deliverable metadata, edited via the
//                    table-editor app at <party>/mdl.table.html
//   - "incoming"   — that party's drop point (we QC then promote)
//   - "received"   — immutable record of incoming we've accepted (WORM)
//   - "issued"     — immutable record of what we sent (WORM)
var PartyFolders = []string{"mdl", "incoming", "received", "issued"}

// AutoOwnCanonicalNames is the subset of canonical folder names where
// the file API's first-write hook auto-writes a creator-owned .zddc
// granting the creator rwcda. Excluded by design:
//
//   - "archive": container only
//   - "reviewing": purely virtual, never on disk
//   - "mdl": yaml data store; ACL flows from archive/<party>/.zddc
//   - "received" / "issued": WORM — auto-own would defeat the mask
var AutoOwnCanonicalNames = []string{"working", "staging", "incoming"}

// VirtualOnlyCanonicalNames is the subset of canonical folder names
// that are never materialised on disk by the auto-create hooks. The
// server treats requests under these prefixes as virtual routes.
//
// "reviewing" stays in ProjectRootFolders so case-fold recognition and
// future tool registration work, but EnsureCanonicalAncestors skips
// MkdirAll for it.
var VirtualOnlyCanonicalNames = []string{"reviewing"}

// IsArchivePartyFolder reports whether dirPath (relative, forward-
// slash-separated) names a canonical per-party folder at exactly
// depth 4: <project>/archive/<party>/<folder>, where <folder> is one
// of mdl/incoming/received/issued (case-insensitive). The party
// segment is verbatim.
//
// Used by listing + dispatch fallbacks so a fresh party that hasn't
// yet had files written to each subfolder still lands on a usable
// empty browse view rather than 404.
func IsArchivePartyFolder(dirPath string) bool {
	clean := strings.Trim(filepath.ToSlash(dirPath), "/")
	if clean == "" {
		return false
	}
	parts := strings.Split(clean, "/")
	if len(parts) != 4 {
		return false
	}
	if !strings.EqualFold(parts[1], "archive") {
		return false
	}
	leaf := strings.ToLower(parts[3])
	for _, name := range PartyFolders {
		if leaf == name {
			return true
		}
	}
	return false
}

// IsArchivePartyMdlDir reports whether dirPath (relative, forward-
// slash-separated) names the default-MDL pattern at exactly depth 4:
// <project>/archive/<party>/mdl. Match is case-insensitive on the
// "archive" and "mdl" segments; the party name is verbatim.
//
// Used by listing + dispatch fallbacks so a fresh party that hasn't
// yet had an MDL written still lands on a usable empty browse / table
// view rather than 404. The companion handler helper
// isAtArchivePartyMdlDir (in internal/handler/tablehandler.go) takes
// absolute paths; this one is the relative-path equivalent for fs.
func IsArchivePartyMdlDir(dirPath string) bool {
	clean := strings.Trim(filepath.ToSlash(dirPath), "/")
	if clean == "" {
		return false
	}
	parts := strings.Split(clean, "/")
	if len(parts) != 4 {
		return false
	}
	return strings.EqualFold(parts[1], "archive") &&
		strings.EqualFold(parts[3], "mdl")
}

// IsProjectRootFolder reports whether dirPath (relative to fsRoot,
// forward-slash-separated, no leading slash) names one of the canonical
// project-root folders at exactly depth 2: <project>/<canonical>.
// Match is case-insensitive against ProjectRootFolders.
//
// Used by the directory listing endpoint to materialise an empty
// listing for canonical folders that don't yet exist on disk, so a
// fresh project's nav links never land on 404. The first write under
// such a path triggers EnsureCanonicalAncestors which lazily creates
// the real on-disk folder + auto-own .zddc.
//
// Trailing slashes and accidental "./" segments are tolerated. Paths
// of any other depth (e.g. project root itself, or deeper subpaths
// like working/<email>/) return false — the fallback only applies at
// the canonical-folder boundary.
func IsProjectRootFolder(dirPath string) bool {
	clean := strings.Trim(filepath.ToSlash(dirPath), "/")
	if clean == "" {
		return false
	}
	parts := strings.Split(clean, "/")
	if len(parts) != 2 {
		return false
	}
	for _, name := range ProjectRootFolders {
		if strings.EqualFold(parts[1], name) {
			return true
		}
	}
	return false
}

// WriteAutoOwnZddc serialises a creator-grant .zddc into dir, granting
// principalEmail rwcda and recording it in CreatedBy. Used by the file
// API's mkdir post-hook (and by EnsureCanonicalAncestors) to seed
// ownership when a new auto-own folder is materialised.
//
// The grant is identical to what an operator would write by hand —
// direct email pattern, "rwcda" verb set — so the creator can later
// edit the file normally to add collaborators.
//
// Atomic: marshals via the same yaml encoder ParseFile reads
// (round-trip guaranteed) and writes via zddc.WriteFile (which
// performs an atomic temp-write + rename via zddc.WriteAtomic).
func WriteAutoOwnZddc(dir, principalEmail string) error {
	return writeAutoOwn(dir, principalEmail, false)
}

// WriteAutoOwnZddcFenced is the same as WriteAutoOwnZddc but additionally
// sets `acl.inherit: false` — fencing ancestor cascade grants. Used at
// per-user home folders under working/ where the convention is "private
// by default; owner edits the file to add collaborators."
//
// Without the fence, an ancestor `*: r` (e.g. a project-root grant for
// authenticated users) would let any user read every other user's
// working subfolder via cascade — defeating the per-user sandbox.
func WriteAutoOwnZddcFenced(dir, principalEmail string) error {
	return writeAutoOwn(dir, principalEmail, true)
}

func writeAutoOwn(dir, principalEmail string, fenced bool) error {
	rules := ACLRules{
		Permissions: map[string]string{principalEmail: "rwcda"},
	}
	if fenced {
		f := false
		rules.Inherit = &f
	}
	zf := ZddcFile{
		ACL:       rules,
		CreatedBy: principalEmail,
	}
	return WriteFile(dir, zf)
}

// ResolveCanonical returns the on-disk name of the canonical folder
// 'logical' (lowercase) inside parentDir, or "" if no case variant
// exists. Caller decides whether to MkdirAll(parentDir+"/"+logical)
// when "" is returned.
//
// 'logical' is matched case-insensitively against entries returned by
// os.ReadDir(parentDir). The first matching directory entry wins (if
// an operator created both Working/ and working/ on a case-sensitive
// filesystem, the order is filesystem-dependent — that's an unsupported
// state we don't try to recover from).
//
// Returns "" with no error if parentDir doesn't exist or has no match.
func ResolveCanonical(parentDir, logical string) (string, error) {
	entries, err := os.ReadDir(parentDir)
	if err != nil {
		if os.IsNotExist(err) {
			return "", nil
		}
		return "", err
	}
	for _, e := range entries {
		if !e.IsDir() {
			continue
		}
		if strings.EqualFold(e.Name(), logical) {
			return e.Name(), nil
		}
	}
	return "", nil
}

// IsAutoOwnPath reports whether parentDir is one of the canonical
// auto-own positions in the ZDDC tree rooted at fsRoot. A child mkdir
// inside such a directory should receive a creator-owned .zddc.
//
// Canonical positions, relative to fsRoot:
//
//   - <project>/working
//   - <project>/staging
//   - <project>/archive/<party>/incoming
//
// Segment matches are case-insensitive on canonical names. The project
// and party names are unrestricted.
//
// parentDir and fsRoot are filesystem paths. parentDir must be inside
// fsRoot; otherwise the function returns false.
func IsAutoOwnPath(parentDir, fsRoot string) bool {
	rel, err := filepath.Rel(fsRoot, parentDir)
	if err != nil {
		return false
	}
	rel = filepath.ToSlash(rel)
	if rel == "." || strings.HasPrefix(rel, "../") || rel == ".." {
		return false
	}
	parts := strings.Split(rel, "/")
	switch len(parts) {
	case 2:
		// <project>/working or <project>/staging
		return strings.EqualFold(parts[1], "working") || strings.EqualFold(parts[1], "staging")
	case 4:
		// <project>/archive/<party>/incoming
		return strings.EqualFold(parts[1], "archive") && strings.EqualFold(parts[3], "incoming")
	}
	return false
}

// IsWormPath reports whether requestPath crosses an
// archive/<party>/received/ or archive/<party>/issued/ segment chain.
// Pure path-segment check; case-fold on canonical names.
//
// The party segment is unrestricted — any directory under archive/ is
// treated as a party, including the self-folder. requestPath may be a
// URL path ("/Project/archive/ACME/issued/foo.pdf") or a filesystem
// path; only segment names matter.
func IsWormPath(requestPath string) bool {
	parts := splitPathSegments(requestPath)
	for i := 0; i+2 < len(parts); i++ {
		if !strings.EqualFold(parts[i], "archive") {
			continue
		}
		// parts[i+1] is the party name (anything).
		if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
			return true
		}
	}
	return false
}

// WormMask reduces a verb set to the subset that survives the WORM
// constraint: the bitwise AND with VerbsRC. Removes w, d, and a.
//
// Callers apply this only when IsWormPath(path) is true AND the
// principal is NOT an admin (root admin or subtree admin) — admins
// are the deliberate escape hatch for mis-filed documents.
//
// The WORM mask is split-aware via WormFolderLevelIndex: grants
// inherited from ancestors above the received/issued folder are
// masked to read only ({r}), while grants at-or-below the WORM
// folder retain {r, c} so an operator can place a .zddc at the
// received/issued folder explicitly granting `_doc_controller: cr`.
func WormMask(grant VerbSet) VerbSet { return grant & VerbsRC }

// WormFolderLevelIndex returns the chain index of the deepest
// archive/<party>/(received|issued) segment in requestPath. The chain
// corresponds to the directory tree from root (index 0) to the
// requested directory; level i is the .zddc at path segment depth i.
//
// numLevels is len(chain.Levels); used to clamp results to the chain's
// actual range. URL segment i lives at chain index i+1 (root is chain
// index 0), so the WORM segment at parts[i+2] corresponds to chain
// index i+3.
//
// Returns -1 if no WORM segment is in the request path or the computed
// index is out of range. The returned index satisfies
// 0 <= index < numLevels.
func WormFolderLevelIndex(requestPath string, numLevels int) int {
	if numLevels <= 0 {
		return -1
	}
	parts := splitPathSegments(requestPath)
	deepest := -1
	for i := 0; i+2 < len(parts); i++ {
		if !strings.EqualFold(parts[i], "archive") {
			continue
		}
		if strings.EqualFold(parts[i+2], "received") || strings.EqualFold(parts[i+2], "issued") {
			idx := i + 3
			if idx < numLevels && idx > deepest {
				deepest = idx
			}
		}
	}
	return deepest
}

// splitPathSegments returns the slash-separated segments of p with
// empty elements removed. Tolerates leading/trailing slashes and
// mixed separators on Windows (via filepath.ToSlash).
func splitPathSegments(p string) []string {
	clean := strings.Trim(filepath.ToSlash(p), "/")
	if clean == "" {
		return nil
	}
	return strings.Split(clean, "/")
}
