package zddc

import (
	"os"

	"gopkg.in/yaml.v3"
)

// ACLRules holds the access-control rules at one cascade level.
//
// Three input forms, all merged at parse time into a single map keyed
// by principal (Permissions):
//
//   - acl.permissions: { principal → verb-set } — the canonical form.
//     Principal is an email pattern (contains "@") or a role name
//     (no "@"); roles are looked up via ZddcFile.Roles in this file
//     or any ancestor. Verb-set is a string drawn from {r,w,c,d,a};
//     empty string is an explicit deny.
//
//   - acl.allow: [pattern, ...] — legacy. Each pattern becomes
//     Permissions[pattern] = "rwcd" at parse time.
//
//   - acl.deny:  [pattern, ...] — legacy. Each pattern becomes
//     Permissions[pattern] = "" at parse time (explicit deny).
//
// Allow and Deny are retained on the struct for round-trip fidelity
// (and so existing operator-authored .zddc files render unchanged in
// the admin UI); the cascade evaluator reads only Permissions.
//
// Inherit controls whether this level imports grants and roles from
// its ancestors. The default (when the field is absent — represented
// here as a nil pointer) is "inherit normally." Setting `inherit: false`
// makes this level a fence: grants and roles defined in any ancestor
// .zddc are invisible at-and-below this point in the cascade. Useful
// for "complete reset, then add back specific principals" patterns
// (e.g. a vendor folder where only the vendor and the doc controller
// should have access regardless of broader project-level grants).
//
// In strict cascade mode (federal / NIST AC-6), inherit:false is
// REFUSED — a leaf-level directive cannot widen access an ancestor
// refused. The internal decider silently treats it as inherit:true;
// the cascade tracer (/.profile/effective-policy) reports both
// `cascade_mode` and `chain.visible_start` so an operator can see
// that a configured fence is being ignored under the active mode.
// Operators running the federal Rego preset get the same behaviour
// from policy enforcement.
//
// Inherit is per-level and not itself cascading: an ancestor's
// `inherit: false` does not transitively block descendants from
// adding their own grants — it only fences off ANCESTORS of the
// fenced level from the descendant subtree.
//
// JSON tags are present so this type round-trips cleanly when included
// in the external-OPA input body (see internal/policy). The canonical
// in-repo serialization is YAML; JSON is only used for OPA queries.
type ACLRules struct {
	Allow       []string          `yaml:"allow,omitempty" json:"allow,omitempty"`
	Deny        []string          `yaml:"deny,omitempty" json:"deny,omitempty"`
	Permissions map[string]string `yaml:"permissions,omitempty" json:"permissions,omitempty"`
	// Inherit *bool: nil = unset (inherit normally), &true = same,
	// &false = fence ancestors. Using a pointer so the default is
	// distinguishable from an explicit `inherit: true`.
	Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`
}

// InheritsAncestors reports whether this level imports grants and
// roles from ancestors. True when Inherit is unset or explicitly true;
// false only when explicitly set to false.
func (r ACLRules) InheritsAncestors() bool {
	return r.Inherit == nil || *r.Inherit
}

// Role is the named principal-grouping primitive. Members are email
// patterns (same syntax as the legacy allow/deny entries — see
// MatchesPattern). A role defined at level L is in scope at L and all
// descendants; a level closer to the leaf may shadow an ancestor's
// role definition by redefining the same name.
type Role struct {
	Members []string `yaml:"members,omitempty" json:"members,omitempty"`
}

// ZddcFile represents the parsed contents of a .zddc configuration file.
//
// Admins is honored only in the root .zddc file (<ZDDC_ROOT>/.zddc); subdir
// .zddc files have their Admins entry ignored by IsAdmin so that someone who
// can write into a subtree cannot grant themselves admin access. ACL on the
// other hand cascades — see EffectivePolicy / AllowedWithChain.
//
// Title is read only from per-project .zddc files (the file directly inside
// each project root) by ServeProjectList; it surfaces a human-readable name
// for the project on the landing-page picker. Optional — projects without a
// title fall back to displaying the directory name.
//
// Apps is a per-directory cascade override mapping app name → source spec.
// The spec is one of: "stable" / "beta" / "alpha" (channel on the canonical
// upstream), "v0.0.4" / "v0.0" / "v0" (version pin on the canonical
// upstream), an absolute "https://..." URL (custom mirror), or a relative
// or absolute filesystem path (./local.html, /opt/zddc/foo.html).
//
// On a request for a tool HTML, zddc-server walks .zddc files leaf→root
// looking for an Apps entry; first match wins. With no entry anywhere, the
// server serves the version baked into the binary at compile time (//go:embed).
// Fetched URL sources are cached in <ZDDC_ROOT>/_app/; the cache is fetch-once
// and never re-validates — operators delete the file to force a refetch.
//
// AppsPubKey is the inline PEM of the Ed25519 public key used to verify
// signatures on URL-fetched apps artifacts. Honored only at the root
// .zddc file (same root-only treatment as Admins, for the same reason:
// it's a trust anchor; subtree write authority must not be able to
// re-anchor it). Lower priority than --apps-pubkey / ZDDC_APPS_PUBKEY:
// when both are set, the env/flag (file path) wins. Empty in either
// place = URL-fetched apps refused (only embedded + local-path apps
// work). See zddc-server's setupApps.
type ZddcFile struct {
	ACL        ACLRules          `yaml:"acl" json:"acl"`
	Admins     []string          `yaml:"admins" json:"admins,omitempty"`
	Title      string            `yaml:"title" json:"title,omitempty"`
	Apps       map[string]string `yaml:"apps,omitempty" json:"apps,omitempty"`
	AppsPubKey string            `yaml:"apps_pubkey,omitempty" json:"apps_pubkey,omitempty"`

	// Tables declares directory-of-YAML table views available at this
	// directory. The map key becomes the URL stem: tables[MDL] is served
	// at <dir>/MDL.table.html. The value is a path (relative to this
	// .zddc) to a *.table.yaml spec describing columns and the rows
	// directory. There is no upward cascade for tables in v1 — each
	// directory that hosts a table declares it directly.
	Tables map[string]string `yaml:"tables,omitempty" json:"tables,omitempty"`

	// Display maps a child entry's on-disk name to a human-friendly
	// label rendered by browse / archive / landing in place of the raw
	// folder name. The on-disk name remains canonical (lowercase for
	// the project-root folders); only the rendered string changes.
	//
	// Match is case-insensitive on the key. Example, on Project-3/.zddc:
	//
	//   display:
	//     archive:   "Records"
	//     working:   "In-Progress"
	//
	// Effect: project-3 listings show "Records" and "In-Progress" in
	// the tree, but URLs still resolve at /Project-3/archive/ and
	// /Project-3/working/. No upward cascade in v1 — a parent .zddc
	// doesn't relabel grand-children. Operators set display: on the
	// directory whose entries they want renamed.
	Display map[string]string `yaml:"display,omitempty" json:"display,omitempty"`

	// Roles are named principal groups available at this level and below.
	// See Role for member syntax.
	Roles map[string]Role `yaml:"roles,omitempty" json:"roles,omitempty"`

	// CreatedBy records the email of the user who triggered the .zddc's
	// creation via the file API's mkdir post-hook (Incoming/Working/Staging
	// only). It is an audit field; the cascade evaluator does not consult
	// it. The auto-generated .zddc grants the creator's email directly via
	// ACL.Permissions, the same way operators grant access to anyone else.
	CreatedBy string `yaml:"created_by,omitempty" json:"created_by,omitempty"`

	// Inherit controls whether the cascade walker descends below this
	// .zddc into lower (ancestor) levels — including the embedded
	// defaults that sit at the bottom. Defaults to true (cascade walks
	// to the root + embedded layer).
	//
	// Set to false on a .zddc to stop the descent: that level becomes
	// the bottom of the effective cascade. Use this at the on-disk
	// root /.zddc to fully ignore the embedded defaults (the operator
	// takes full responsibility for spelling out every rule from
	// scratch). Useful at deeper levels too — e.g. a sandbox subtree
	// that wants none of the project's policy.
	//
	// Pointer so an unset value (nil) is distinguishable from explicit
	// false. nil == defaults to true.
	Inherit *bool `yaml:"inherit,omitempty" json:"inherit,omitempty"`

	// DefaultTool is the tool name served at this directory's
	// no-slash URL form (e.g. /Project/working without trailing slash
	// → mdedit). Empty means "no default" — the slash convention's
	// browse listing wins and the no-slash form 302s. Cascades
	// through Paths: an ancestor's Paths entry can set DefaultTool
	// for a virtual descendant without anyone creating that dir.
	DefaultTool string `yaml:"default_tool,omitempty" json:"default_tool,omitempty"`

	// AutoOwn controls whether the file API's mkdir post-hook writes
	// an auto-owned .zddc granting the creator rwcda at the new
	// directory. Useful for working/staging/incoming-style drafting
	// surfaces where the first creator should "own" what they
	// created. Empty (nil) inherits via cascade.
	AutoOwn *bool `yaml:"auto_own,omitempty" json:"auto_own,omitempty"`

	// Virtual marks a directory as never-materialise-on-disk. The
	// server treats requests under such a path as virtual routes
	// rather than triggering EnsureCanonicalAncestors. The reviewing
	// aggregator is the canonical example. Empty (nil) inherits via
	// cascade.
	Virtual *bool `yaml:"virtual,omitempty" json:"virtual,omitempty"`

	// Paths declares virtual sub-directory rules without those
	// directories needing to exist on disk. Each key is a single path
	// segment — either a literal name or `*` (matches any segment).
	// The value is a nested ZddcFile that applies at the matching
	// child directory.
	//
	// Recursive: a Paths entry's value may itself have a Paths map,
	// matching further-down segments.
	//
	// Example, for a tree where every project should treat archive/
	// as the workflow archive and a party's incoming/ as the
	// classifier landing zone — without anyone creating those folders
	// on disk:
	//
	//   paths:
	//     "*":              # project name (any)
	//       paths:
	//         archive:
	//           paths:
	//             "*":      # party name
	//               paths:
	//                 incoming:
	//                   default_tool: classifier
	//
	// Match: literal-segment key first (case-insensitive); fall back
	// to a "*" key if present. Multi-segment keys (e.g. "a/b") are
	// NOT supported in v1 — express depth via nested Paths blocks.
	//
	// Virtual contributions from ancestor Paths are merged into the
	// effective ZddcFile at each level by EffectivePolicy. On-disk
	// .zddc at the matching directory wins per-field (most specific
	// overrides). An inherit:false on any level drops the ancestor
	// contributions from that level and below.
	Paths map[string]ZddcFile `yaml:"paths,omitempty" json:"paths,omitempty"`
}

// ParseFile reads and parses a .zddc YAML file.
// Returns an empty ZddcFile (no rules) if the file does not exist.
func ParseFile(path string) (ZddcFile, error) {
	data, err := os.ReadFile(path)
	if os.IsNotExist(err) {
		return ZddcFile{}, nil
	}
	if err != nil {
		return ZddcFile{}, err
	}
	return parseBytes(data)
}

// parseBytes is the shared YAML→ZddcFile path used by ParseFile and
// EmbeddedDefaults. Returns a zero ZddcFile if data is empty.
func parseBytes(data []byte) (ZddcFile, error) {
	if len(data) == 0 {
		return ZddcFile{}, nil
	}
	var zf ZddcFile
	if err := yaml.Unmarshal(data, &zf); err != nil {
		return ZddcFile{}, err
	}
	mergeLegacyACL(&zf.ACL)
	return zf, nil
}

// mergeLegacyACL folds legacy acl.allow / acl.deny lists into the
// canonical ACL.Permissions map so cascade evaluators only need to
// consult one place. Existing entries in Permissions take precedence
// (operators who specified both forms get the new form's value);
// allow entries become "rwcd" grants, deny entries become "" denies.
func mergeLegacyACL(rules *ACLRules) {
	if len(rules.Allow) == 0 && len(rules.Deny) == 0 {
		return
	}
	if rules.Permissions == nil {
		rules.Permissions = make(map[string]string, len(rules.Allow)+len(rules.Deny))
	}
	for _, pat := range rules.Allow {
		if _, present := rules.Permissions[pat]; !present {
			rules.Permissions[pat] = "rwcd"
		}
	}
	for _, pat := range rules.Deny {
		if _, present := rules.Permissions[pat]; !present {
			rules.Permissions[pat] = ""
		}
	}
}
