//! Config-driven layout resolver for ontoref's consumer-facing artefacts.
//!
//! Section directories (ontology, adrs, reflection, catalog, artifacts) and
//! the project beacon (`card.ncl`) are addressed through a [`LayoutConfig`]
//! whose fields are paths relative to the project root. The default layout
//! matches ADR-032 (consolidated under `.ontoref/<section>/`), but projects
//! that need a different layout can override any field via their
//! `.ontoref/config.ncl` `[layout]` section without changing call-site code.
//!
//! Two operational regimes coexist during the 0.1.0 → 0.1.1 migration:
//!
//! 1. **Resolved (read) paths** call [`LayoutConfig::resolve`] (or the
//!    convenience [`resolve_section`] which uses the default layout). The
//!    resolver checks the canonical configured path first, then falls back to
//!    the legacy root-level layout for projects that have not yet applied
//!    migration 0023.
//! 2. **Canonical (write) paths** call [`LayoutConfig::canonical`] (or
//!    [`canonical_section`]). Writes always target the configured layout —
//!    never the legacy locations — so new content lands where the project
//!    declares, not where ontoref's defaults said to put it.
//!
//! The dual-path fallback is a finite transition affordance per ADR-032
//! Hard constraint `daemon-resolves-dual-paths-during-transition` and will
//! be removed when the legacy layout is fully retired in 0.2.0.

use std::path::{Path, PathBuf};

use tracing::debug;

/// Per-project layout declaration. All paths are relative to the project root.
///
/// Defaults match ADR-032 (`.ontoref/<section>/`). Override any field via
/// `.ontoref/config.ncl` `[layout]` to relocate that section without changing
/// any code that consumes ontoref's typed artefacts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayoutConfig {
    /// Directory holding `core.ncl`, `state.ncl`, `gate.ncl`, `manifest.ncl`,
    /// `connections.ncl`, `_refs.ncl`, and any project-specific ontology
    /// extensions.
    pub ontology_dir: PathBuf,
    /// Directory holding `adr-NNN-*.ncl` files plus `_template.ncl`.
    pub adrs_dir: PathBuf,
    /// Directory holding `qa.ncl`, `backlog.ncl`, `modes/`, `forms/`,
    /// `schemas/`, `migrations/`, `nulib/`, `bin/`, and friends.
    pub reflection_dir: PathBuf,
    /// Directory holding `schema.ncl`, `operations/`, `validators/`.
    pub catalog_dir: PathBuf,
    /// Directory for generated artefacts (e.g. `api-catalog-*.ncl`).
    pub artifacts_dir: PathBuf,
    /// Path to the project beacon `card.ncl`.
    pub card_path: PathBuf,
}

impl Default for LayoutConfig {
    /// Default layout — matches the protocol version 0.1.1 canonical layout
    /// (ADR-032, applied by migration 0023). When the protocol baseline
    /// shifts in a future version, this constructor is the single point
    /// where the new defaults land; `LayoutConfig::legacy()` continues to
    /// return the pre-0023 paths for the dual-path resolver.
    fn default() -> Self {
        Self {
            ontology_dir: PathBuf::from(".ontoref/ontology"),
            adrs_dir: PathBuf::from(".ontoref/adrs"),
            reflection_dir: PathBuf::from(".ontoref/reflection"),
            catalog_dir: PathBuf::from(".ontoref/catalog"),
            artifacts_dir: PathBuf::from(".ontoref/artifacts"),
            card_path: PathBuf::from(".ontoref/card.ncl"),
        }
    }
}

impl LayoutConfig {
    /// Pre-0023 layout where everything lives at project root (`.ontology/`,
    /// `adrs/`, `reflection/`, `catalog/`, `artifacts/`, `card.ncl`). Returned
    /// from [`Self::detect`] when only the legacy layout is present on disk.
    pub fn legacy() -> Self {
        Self {
            ontology_dir: PathBuf::from(".ontology"),
            adrs_dir: PathBuf::from("adrs"),
            reflection_dir: PathBuf::from("reflection"),
            catalog_dir: PathBuf::from("catalog"),
            artifacts_dir: PathBuf::from("artifacts"),
            card_path: PathBuf::from("card.ncl"),
        }
    }

    /// Build a [`LayoutConfig`] from a parsed `.ontoref/config.ncl` JSON value.
    ///
    /// Looks at the top-level `layout` object. Each field is optional and
    /// overrides the corresponding default. Unknown keys are ignored. The
    /// caller is responsible for shelling out to `nickel export` (or reusing
    /// the daemon's cache) and passing the resulting [`serde_json::Value`] —
    /// `ontoref-ontology` deliberately does not link a Nickel runtime per
    /// the zero-deps constraint from ADR-001.
    ///
    /// Returns [`Self::default`] when the input has no `layout` key.
    pub fn from_config_json(config: &serde_json::Value) -> Self {
        let mut layout = Self::default();
        let Some(section) = config.get("layout") else {
            return layout;
        };
        if let Some(s) = section.get("ontology_dir").and_then(|v| v.as_str()) {
            layout.ontology_dir = PathBuf::from(s);
        }
        if let Some(s) = section.get("adrs_dir").and_then(|v| v.as_str()) {
            layout.adrs_dir = PathBuf::from(s);
        }
        if let Some(s) = section.get("reflection_dir").and_then(|v| v.as_str()) {
            layout.reflection_dir = PathBuf::from(s);
        }
        if let Some(s) = section.get("catalog_dir").and_then(|v| v.as_str()) {
            layout.catalog_dir = PathBuf::from(s);
        }
        if let Some(s) = section.get("artifacts_dir").and_then(|v| v.as_str()) {
            layout.artifacts_dir = PathBuf::from(s);
        }
        if let Some(s) = section.get("card_path").and_then(|v| v.as_str()) {
            layout.card_path = PathBuf::from(s);
        }
        layout
    }

    /// Detect which layout a project on disk uses. Returns [`Self::default`]
    /// when `<root>/.ontoref/` exists (post-0023), [`Self::legacy`] when only
    /// the legacy root-level directories are present, and [`Self::default`]
    /// when neither is materialised (so writes land in the canonical layout).
    pub fn detect(project_root: &Path) -> Self {
        if project_root.join(".ontoref").is_dir() {
            return Self::default();
        }
        let legacy = Self::legacy();
        if project_root.join(&legacy.ontology_dir).is_dir()
            || project_root.join(&legacy.adrs_dir).is_dir()
            || project_root.join(&legacy.reflection_dir).is_dir()
        {
            return legacy;
        }
        Self::default()
    }

    /// Resolve a logical section path against this layout, with legacy
    /// fallback. The legacy fallback uses [`Self::legacy`] so the dual-path
    /// transition works for arbitrary configured layouts: a project whose
    /// `ontology_dir` is `"data/ontology"` still finds files in `.ontology/`
    /// if that's where they live on disk.
    ///
    /// Emits a `tracing::debug!` event when falling back to the legacy
    /// path, per ADR-032 constraint
    /// `daemon-resolves-dual-paths-during-transition`. Callers that want a
    /// one-shot info-level signal at registration time should use
    /// [`is_legacy_layout`] instead.
    pub fn resolve(&self, root: &Path, logical: &str) -> PathBuf {
        let configured = self.canonical(root, logical);
        if configured.exists() {
            return configured;
        }
        let legacy = Self::legacy().canonical(root, logical);
        if legacy.exists() {
            debug!(
                logical,
                canonical = %configured.display(),
                legacy = %legacy.display(),
                "dual-path resolver: falling back to legacy layout — project has not applied migration 0023"
            );
            return legacy;
        }
        configured
    }

    /// Returns the configured path for a logical section without checking
    /// whether it exists on disk. Use this for WRITE operations that must
    /// always target the configured layout, never the legacy fallback.
    ///
    /// Accepts both whole-section names (`"adrs"`, `"ontology"`) and
    /// section-relative paths (`"adrs/adr-001.ncl"`, `"ontology/core.ncl"`).
    pub fn canonical(&self, root: &Path, logical: &str) -> PathBuf {
        let (section_dir, rest) = self.split_logical(logical);
        match section_dir {
            Some(dir) => {
                if rest.is_empty() {
                    root.join(dir)
                } else {
                    root.join(dir).join(rest)
                }
            }
            None => {
                if logical == "card.ncl" {
                    root.join(&self.card_path)
                } else {
                    root.join(logical)
                }
            }
        }
    }

    fn split_logical<'a>(&'a self, logical: &'a str) -> (Option<&'a Path>, &'a str) {
        for (name, dir) in [
            ("ontology", self.ontology_dir.as_path()),
            ("adrs", self.adrs_dir.as_path()),
            ("reflection", self.reflection_dir.as_path()),
            ("catalog", self.catalog_dir.as_path()),
            ("artifacts", self.artifacts_dir.as_path()),
        ] {
            if logical == name {
                return (Some(dir), "");
            }
            if let Some(rest) = logical.strip_prefix(&format!("{name}/")) {
                return (Some(dir), rest);
            }
        }
        (None, logical)
    }

    /// Whether a watcher-reported relative path belongs to the named section
    /// under this layout. Accepts both the configured prefix and the legacy
    /// prefix so the watcher matches across migrated and unmigrated projects.
    pub fn relative_in_section(&self, relative_path: &str, section: &str) -> bool {
        let configured_prefix = match section {
            "ontology" => self.ontology_dir.as_path(),
            "adrs" => self.adrs_dir.as_path(),
            "reflection" => self.reflection_dir.as_path(),
            "catalog" => self.catalog_dir.as_path(),
            "artifacts" => self.artifacts_dir.as_path(),
            _ => return false,
        };
        let legacy = Self::legacy();
        let legacy_prefix = match section {
            "ontology" => legacy.ontology_dir,
            "adrs" => legacy.adrs_dir,
            "reflection" => legacy.reflection_dir,
            "catalog" => legacy.catalog_dir,
            "artifacts" => legacy.artifacts_dir,
            _ => return false,
        };
        matches_prefix(relative_path, configured_prefix)
            || matches_prefix(relative_path, legacy_prefix.as_path())
    }
}

fn matches_prefix(relative_path: &str, prefix: &Path) -> bool {
    let prefix_str = prefix.to_string_lossy();
    let prefix_slash = format!("{prefix_str}/");
    let prefix_back = format!("{}\\", prefix_str.replace('/', "\\"));
    relative_path.starts_with(prefix_slash.as_str())
        || relative_path.starts_with(prefix_back.as_str())
}

/// Resolve a logical ontoref section path under the default layout
/// ([`LayoutConfig::default`]) with legacy fallback. Suitable for call sites
/// that don't yet thread an explicit `LayoutConfig` through their context.
pub fn resolve_section(root: &Path, logical: &str) -> PathBuf {
    LayoutConfig::default().resolve(root, logical)
}

/// Returns the canonical (default-layout) path for a logical section without
/// checking whether it exists on disk. Use for write operations.
pub fn canonical_section(root: &Path, logical: &str) -> PathBuf {
    LayoutConfig::default().canonical(root, logical)
}

/// Returns the legacy (pre-0023) path for a logical section without checking
/// whether it exists on disk.
pub fn legacy_section(root: impl AsRef<Path>, logical: &str) -> PathBuf {
    LayoutConfig::legacy().canonical(root.as_ref(), logical)
}

/// Watcher prefix-match against the default layout (with legacy fallback).
pub fn relative_in_section(relative_path: &str, section: &str) -> bool {
    LayoutConfig::default().relative_in_section(relative_path, section)
}

/// Returns `true` when the project on disk uses the pre-0023 (legacy) layout.
///
/// Use this at registration time (or other one-shot init paths) to emit a
/// single, prominent `info!`-level log noting the project has not yet applied
/// migration 0023. Per-call fallbacks in [`LayoutConfig::resolve`] log at
/// `debug!` to avoid flooding the trace stream during normal operation.
///
/// Returns `false` for an empty project (no `.ontoref/` and no legacy
/// directories) — there is no "legacy layout" to surface.
pub fn is_legacy_layout(project_root: &Path) -> bool {
    let canonical_present = project_root.join(".ontoref").is_dir();
    if canonical_present {
        return false;
    }
    let legacy = LayoutConfig::legacy();
    project_root.join(&legacy.ontology_dir).is_dir()
        || project_root.join(&legacy.adrs_dir).is_dir()
        || project_root.join(&legacy.reflection_dir).is_dir()
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::TempDir;

    use super::*;

    #[test]
    fn resolve_prefers_canonical_when_both_exist() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(".ontoref/ontology")).unwrap();
        fs::create_dir_all(root.join(".ontology")).unwrap();
        fs::write(root.join(".ontoref/ontology/core.ncl"), "canonical").unwrap();
        fs::write(root.join(".ontology/core.ncl"), "legacy").unwrap();
        let resolved = resolve_section(root, "ontology/core.ncl");
        assert_eq!(resolved, root.join(".ontoref/ontology/core.ncl"));
    }

    #[test]
    fn resolve_falls_back_to_legacy_when_canonical_absent() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(".ontology")).unwrap();
        fs::write(root.join(".ontology/core.ncl"), "legacy").unwrap();
        let resolved = resolve_section(root, "ontology/core.ncl");
        assert_eq!(resolved, root.join(".ontology/core.ncl"));
    }

    #[test]
    fn resolve_returns_canonical_when_neither_exists() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        let resolved = resolve_section(root, "ontology/core.ncl");
        assert_eq!(resolved, root.join(".ontoref/ontology/core.ncl"));
    }

    #[test]
    fn resolve_handles_non_ontology_sections() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join("reflection")).unwrap();
        fs::write(root.join("reflection/backlog.ncl"), "legacy").unwrap();
        let resolved = resolve_section(root, "reflection/backlog.ncl");
        assert_eq!(resolved, root.join("reflection/backlog.ncl"));
    }

    #[test]
    fn resolve_card_falls_back_when_canonical_absent() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::write(root.join("card.ncl"), "legacy").unwrap();
        let resolved = resolve_section(root, "card.ncl");
        assert_eq!(resolved, root.join("card.ncl"));
    }

    #[test]
    fn canonical_section_never_checks_disk() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        let p = canonical_section(root, "adrs/adr-001.ncl");
        assert_eq!(p, root.join(".ontoref/adrs/adr-001.ncl"));
        assert!(!p.exists());
    }

    #[test]
    fn legacy_section_maps_ontology_prefix_only() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        assert_eq!(
            legacy_section(root, "ontology/core.ncl"),
            root.join(".ontology/core.ncl")
        );
        assert_eq!(
            legacy_section(root, "adrs/adr-001.ncl"),
            root.join("adrs/adr-001.ncl")
        );
        assert_eq!(legacy_section(root, "card.ncl"), root.join("card.ncl"));
    }

    #[test]
    fn relative_in_section_matches_both_prefixes() {
        assert!(relative_in_section(
            ".ontoref/ontology/core.ncl",
            "ontology"
        ));
        assert!(relative_in_section(".ontology/core.ncl", "ontology"));
        assert!(relative_in_section(".ontoref/adrs/adr-001.ncl", "adrs"));
        assert!(relative_in_section("adrs/adr-001.ncl", "adrs"));
        assert!(relative_in_section(
            ".ontoref/reflection/modes/sync.ncl",
            "reflection"
        ));
        assert!(relative_in_section("reflection/qa.ncl", "reflection"));
        assert!(!relative_in_section("crates/foo/bar.rs", "ontology"));
        assert!(!relative_in_section("Cargo.toml", "reflection"));
    }

    #[test]
    fn custom_layout_routes_to_configured_dirs() {
        let custom = LayoutConfig {
            ontology_dir: PathBuf::from("data/ontology"),
            adrs_dir: PathBuf::from("docs/decisions"),
            reflection_dir: PathBuf::from(".ontoref/reflection"),
            catalog_dir: PathBuf::from(".ontoref/catalog"),
            artifacts_dir: PathBuf::from("build/artifacts"),
            card_path: PathBuf::from("project.card.ncl"),
        };
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        assert_eq!(
            custom.canonical(root, "ontology/core.ncl"),
            root.join("data/ontology/core.ncl")
        );
        assert_eq!(
            custom.canonical(root, "adrs/adr-001.ncl"),
            root.join("docs/decisions/adr-001.ncl")
        );
        assert_eq!(
            custom.canonical(root, "artifacts/api-catalog.ncl"),
            root.join("build/artifacts/api-catalog.ncl")
        );
        assert_eq!(
            custom.canonical(root, "card.ncl"),
            root.join("project.card.ncl")
        );
    }

    #[test]
    fn custom_layout_resolve_falls_back_to_legacy_disk_locations() {
        let custom = LayoutConfig {
            ontology_dir: PathBuf::from("data/ontology"),
            ..LayoutConfig::default()
        };
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(".ontology")).unwrap();
        fs::write(root.join(".ontology/core.ncl"), "legacy").unwrap();
        let resolved = custom.resolve(root, "ontology/core.ncl");
        assert_eq!(resolved, root.join(".ontology/core.ncl"));
    }

    #[test]
    fn detect_returns_legacy_when_only_legacy_present() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(".ontology")).unwrap();
        let detected = LayoutConfig::detect(root);
        assert_eq!(detected, LayoutConfig::legacy());
    }

    #[test]
    fn detect_returns_default_when_canonical_present() {
        let dir = TempDir::new().expect("tempdir");
        let root = dir.path();
        fs::create_dir_all(root.join(".ontoref")).unwrap();
        let detected = LayoutConfig::detect(root);
        assert_eq!(detected, LayoutConfig::default());
    }

    #[test]
    fn detect_returns_default_when_neither_present() {
        let dir = TempDir::new().expect("tempdir");
        let detected = LayoutConfig::detect(dir.path());
        assert_eq!(detected, LayoutConfig::default());
    }

    #[test]
    fn custom_layout_relative_in_section_matches_configured_prefix() {
        let custom = LayoutConfig {
            ontology_dir: PathBuf::from("data/ontology"),
            ..LayoutConfig::default()
        };
        assert!(custom.relative_in_section("data/ontology/core.ncl", "ontology"));
        assert!(custom.relative_in_section(".ontology/core.ncl", "ontology")); // legacy fallback
        assert!(!custom.relative_in_section("src/main.rs", "ontology"));
    }

    #[test]
    fn from_config_json_without_layout_section_returns_default() {
        let config = serde_json::json!({ "log": { "level": "info" } });
        let layout = LayoutConfig::from_config_json(&config);
        assert_eq!(layout, LayoutConfig::default());
    }

    #[test]
    fn from_config_json_overrides_only_provided_fields() {
        let config = serde_json::json!({
            "layout": {
                "ontology_dir": "data/ontology",
                "adrs_dir": "docs/decisions",
            }
        });
        let layout = LayoutConfig::from_config_json(&config);
        assert_eq!(layout.ontology_dir, PathBuf::from("data/ontology"));
        assert_eq!(layout.adrs_dir, PathBuf::from("docs/decisions"));
        // Untouched fields keep the default.
        assert_eq!(
            layout.reflection_dir,
            LayoutConfig::default().reflection_dir
        );
        assert_eq!(layout.card_path, LayoutConfig::default().card_path);
    }

    #[test]
    fn from_config_json_ignores_unknown_keys() {
        let config = serde_json::json!({
            "layout": {
                "ontology_dir": "x/ontology",
                "totally_made_up_key": "ignored",
            }
        });
        let layout = LayoutConfig::from_config_json(&config);
        assert_eq!(layout.ontology_dir, PathBuf::from("x/ontology"));
        assert_eq!(layout.adrs_dir, LayoutConfig::default().adrs_dir);
    }

    #[test]
    fn is_legacy_layout_returns_true_only_for_pre_0023_disks() {
        // empty project — no layout at all
        let dir = TempDir::new().expect("tempdir");
        assert!(!is_legacy_layout(dir.path()));

        // canonical layout present → not legacy
        let dir = TempDir::new().expect("tempdir");
        fs::create_dir_all(dir.path().join(".ontoref/ontology")).unwrap();
        assert!(!is_legacy_layout(dir.path()));

        // legacy layout present → legacy
        let dir = TempDir::new().expect("tempdir");
        fs::create_dir_all(dir.path().join(".ontology")).unwrap();
        assert!(is_legacy_layout(dir.path()));

        let dir = TempDir::new().expect("tempdir");
        fs::create_dir_all(dir.path().join("adrs")).unwrap();
        assert!(is_legacy_layout(dir.path()));

        // both present → canonical wins (not legacy)
        let dir = TempDir::new().expect("tempdir");
        fs::create_dir_all(dir.path().join(".ontoref/ontology")).unwrap();
        fs::create_dir_all(dir.path().join(".ontology")).unwrap();
        assert!(!is_legacy_layout(dir.path()));
    }

    #[test]
    fn from_config_json_ignores_wrong_typed_field() {
        let config = serde_json::json!({
            "layout": {
                "ontology_dir": 42,  // not a string
            }
        });
        let layout = LayoutConfig::from_config_json(&config);
        assert_eq!(layout.ontology_dir, LayoutConfig::default().ontology_dir);
    }
}
