//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
//! apply}` endpoints consume. Each manifest becomes one node (app or group).
//!
//! §6: a GROUP node's PARENT is inferred from directory nesting — its parent is
//! the nearest ancestor directory that also holds a `[group]` manifest; the
//! topmost group binds to the repo's `[project] parent_group` (attach point) or
//! the instance root. The server uses this declared parent to create a missing
//! group and to detect structural divergence (M2). An app node declares no
//! parent (an app pre-exists with a fixed group).

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde_json::{json, Value};

use crate::cmds::plan::build_bundle;
use crate::manifest::{Manifest, ManifestProject, MANIFEST_FILE};

/// Find every `picloud.toml` under `root` (recursively), skipping the
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
    let mut out = Vec::new();
    walk(root, &mut out)?;
    out.sort();
    Ok(out)
}

fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
    let entries =
        std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
    for entry in entries {
        let entry = entry?;
        let name = entry.file_name();
        let ft = entry.file_type()?;
        if ft.is_dir() {
            if matches!(
                name.to_str(),
                Some(".picloud" | ".git" | "scripts" | "target")
            ) {
                continue;
            }
            walk(&entry.path(), out)?;
        } else if name == MANIFEST_FILE {
            out.push(entry.path());
        }
    }
    Ok(())
}

/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON, the node count, and the owning
/// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one
/// project owns the whole tree; a `[project]` in any other node is ignored with
/// a note. Rejects a tree that names the same (kind, slug) twice.
pub fn build_tree(
    root: &Path,
    env: Option<&str>,
) -> Result<(Value, usize, Option<ManifestProject>)> {
    let paths = find_manifests(root)?;
    if paths.is_empty() {
        bail!(
            "no {MANIFEST_FILE} found under {} — nothing to apply",
            root.display()
        );
    }
    let root_manifest = root.join(MANIFEST_FILE);
    // Load every manifest once, remembering its directory so we can infer group
    // parentage from the nesting.
    let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
    let mut project: Option<ManifestProject> = None;
    for path in &paths {
        let manifest = Manifest::load_with_env(path, env)
            .with_context(|| format!("loading {}", path.display()))?;
        if let Some(p) = &manifest.project {
            if path.as_path() == root_manifest {
                project = Some(p.clone());
            } else {
                eprintln!(
                    "note: [project] in {} is ignored — declare it in the tree root {}",
                    path.display(),
                    root_manifest.display()
                );
                // §3 M3: a non-root [project] that carries env-approval policy is
                // a silent-safety trap — the gate is dropped with the block. Call
                // it out explicitly so a misplaced gate isn't mistaken for active.
                if !p.environments.is_empty() {
                    eprintln!(
                        "warning: its [project.environments] approval gating is NOT enforced — \
                         only the tree root's [project] governs env approval"
                    );
                }
            }
        }
        loaded.push((path.clone(), manifest));
    }

    // Map each GROUP manifest's DIRECTORY → its slug, so a group node can find
    // its nearest ancestor-directory group (its declared parent).
    let group_dir_to_slug: HashMap<PathBuf, String> = loaded
        .iter()
        .filter(|(_, m)| m.is_group())
        .map(|(path, m)| {
            let dir = path
                .parent()
                .unwrap_or_else(|| Path::new("."))
                .to_path_buf();
            (dir, m.slug().to_string())
        })
        .collect();
    let attach_point = project.as_ref().and_then(|p| p.parent_group.clone());

    // Build (sort_key, node) pairs. Emit groups before apps, and shallower
    // groups before deeper ones (a parent's directory has fewer components than
    // its child's), so the server creates a parent group before the child that
    // nests under it.
    let mut keyed: Vec<((bool, usize), Value)> = Vec::with_capacity(loaded.len());
    let mut seen: HashSet<(String, String)> = HashSet::new();
    for (path, manifest) in &loaded {
        let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
        let bundle = build_bundle(manifest, base_dir)?;
        let is_group = manifest.is_group();
        let kind = if is_group { "group" } else { "app" };
        let slug = manifest.slug().to_string();
        if !seen.insert((kind.to_string(), slug.clone())) {
            bail!("project tree declares {kind} `{slug}` more than once");
        }
        let depth = base_dir.components().count();
        let node = if is_group {
            let parent = nearest_group_ancestor(base_dir, &group_dir_to_slug)
                .or_else(|| attach_point.clone());
            let g = manifest.group.as_ref();
            let name = g.map(|g| g.name.clone());
            let description = g.and_then(|g| g.description.clone());
            json!({
                "kind": kind, "slug": slug, "parent": parent,
                "name": name, "description": description, "bundle": bundle,
            })
        } else {
            json!({ "kind": kind, "slug": slug, "bundle": bundle })
        };
        keyed.push(((!is_group, depth), node));
    }
    keyed.sort_by(|a, b| a.0.cmp(&b.0));
    let nodes: Vec<Value> = keyed.into_iter().map(|(_, n)| n).collect();
    let count = nodes.len();
    Ok((json!({ "nodes": nodes }), count, project))
}

/// The slug of the nearest STRICT ancestor directory of `dir` that holds a
/// `[group]` manifest, or `None` if there is no group above it in the tree.
fn nearest_group_ancestor(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
    dir.ancestors()
        .skip(1) // skip `dir` itself — we want a strict ancestor
        .find_map(|anc| group_dirs.get(anc).cloned())
}
