//! High-level operations shared by every front-end (plan §3.1).
//!
//! `EngineCtx` resolves config for a repo; the functions here do the actual work
//! (`ask`, plus the read-only accessors MCP/web need) and emit progress through a
//! [`Progress`] sink. Front-ends render the results.
//!
//! Scope decision: the MCP/web interfaces are read-only by design, so the engine's
//! shared surface is `ask` + read accessors (`list_pages`/`read_page`/`status`).
//! The mutating builds (`init`/`update`/`lint`) are intentionally CLI-first in v1;
//! lifting them here (to enable `--allow-write` on MCP/web) is a tracked follow-up.

use crate::agent::{prompts, AgentTask};
use crate::config::{self, Config, Op, Overrides, Paths};
use crate::progress::Progress;
use crate::providers::{self, Provider};
use crate::state::Manifest;
use crate::wiki::frontmatter::Frontmatter;
use crate::wiki::{frontmatter, index, log};
use crate::{repo, util};
use anyhow::{anyhow, bail, Context, Result};
use std::path::{Path, PathBuf};

/// Resolved context for engine operations against a repository.
pub struct EngineCtx {
    pub repo_root: PathBuf,
    pub paths: Paths,
    pub cfg: Config,
}

impl EngineCtx {
    /// Build from an already-resolved config (used by the CLI after its wizard).
    pub fn new(repo_root: PathBuf, cfg: Config) -> EngineCtx {
        let paths = Paths::new(&repo_root);
        EngineCtx {
            repo_root,
            paths,
            cfg,
        }
    }

    /// Resolve config non-interactively (used by MCP/web). Errors if no provider
    /// is configured — never runs the interactive selector.
    pub fn load(repo_root: &Path, ov: &Overrides) -> Result<EngineCtx> {
        let paths = Paths::new(repo_root);
        let cfg = config::resolve(&paths, ov)?.ok_or_else(|| {
            anyhow!(
                "no provider configured — run `hephaes login` or set --provider/HEPHAES_PROVIDER"
            )
        })?;
        Ok(EngineCtx {
            repo_root: repo_root.to_path_buf(),
            paths,
            cfg,
        })
    }

    /// Absolute wiki directory.
    pub fn wiki_dir(&self) -> PathBuf {
        let p = Path::new(&self.cfg.wiki_dir);
        if p.is_absolute() {
            p.to_path_buf()
        } else {
            self.repo_root.join(p)
        }
    }

    fn project_name(&self) -> String {
        self.repo_root
            .file_name()
            .and_then(|s| s.to_str())
            .unwrap_or("project")
            .to_string()
    }
}

#[derive(Debug, Clone, Default)]
pub struct AskOptions {
    pub from_source: bool,
    pub save: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CitationKind {
    /// A wiki page (`[[wikilink]]`).
    Page,
    /// A source location (`path:line`).
    Source,
}

#[derive(Debug, Clone)]
pub struct Citation {
    pub kind: CitationKind,
    pub target: String,
    pub line: Option<u32>,
}

#[derive(Debug, Clone, Default)]
pub struct AskResult {
    pub answer: String,
    /// Citations parsed (best-effort) from the answer — wiki pages + file:line.
    pub citations: Vec<Citation>,
    pub saved_page: Option<String>,
    pub cost_usd: Option<f64>,
}

/// Best-effort citation extraction from an answer: `[[wikilinks]]` → page
/// citations, `path.ext:line` → source citations.
fn parse_citations(answer: &str) -> Vec<Citation> {
    let mut out = Vec::new();
    let wl = regex::Regex::new(r"\[\[([^\]|]+)").unwrap();
    for c in wl.captures_iter(answer) {
        out.push(Citation {
            kind: CitationKind::Page,
            target: c[1].trim().to_string(),
            line: None,
        });
    }
    let src = regex::Regex::new(r"\b([\w./-]+\.[A-Za-z]{1,8}):(\d+)").unwrap();
    for c in src.captures_iter(answer) {
        out.push(Citation {
            kind: CitationKind::Source,
            target: c[1].to_string(),
            line: c[2].parse().ok(),
        });
    }
    out
}

/// Answer a question against the wiki (read-only), optionally filing the answer
/// back as a new page. Shared by CLI `ask`, the MCP `ask` tool, and web `/api/ask`.
pub async fn ask(
    ctx: &EngineCtx,
    question: &str,
    opts: &AskOptions,
    progress: &dyn Progress,
) -> Result<AskResult> {
    let provider = providers::build(&ctx.cfg, &ctx.paths, ctx.cfg.model_for(Op::Ask))?;
    ask_with(ctx, provider.as_ref(), question, opts, progress).await
}

/// Like [`ask`] but with an injected provider — the seam used by tests
/// (with a `MockProvider`) so engine behavior can be checked without cost.
pub async fn ask_with(
    ctx: &EngineCtx,
    provider: &dyn Provider,
    question: &str,
    opts: &AskOptions,
    progress: &dyn Progress,
) -> Result<AskResult> {
    let wiki = ctx.wiki_dir();
    if !wiki.exists() {
        bail!(
            "no wiki found at {} — run `hephaes init` first",
            ctx.cfg.wiki_dir
        );
    }
    let question = question.trim();
    if question.is_empty() {
        bail!("empty question");
    }

    let index_content = std::fs::read_to_string(wiki.join("index.md")).unwrap_or_else(|_| {
        index::list_pages(&wiki)
            .iter()
            .map(|p| format!("- {} — {}", p.rel, p.title))
            .collect::<Vec<_>>()
            .join("\n")
    });

    let project = ctx.project_name();

    let task = AgentTask {
        system: prompts::system_prompt(&project),
        instruction: prompts::ask_instruction(question, opts.from_source),
        context: format!("# index.md\n\n{index_content}"),
        repo_map: String::new(),
        repo_root: ctx.repo_root.clone(),
        wiki_dir: wiki.clone(),
        model: ctx.cfg.model_for(Op::Ask),
        read_only: true,
        max_iters: 40,
    };

    let outcome = provider.run_agent(&task, progress).await?;
    let answer = outcome.final_text.trim().to_string();

    let mut saved_page = None;
    if opts.save && !answer.is_empty() {
        let slug = util::slugify(question);
        let rel = format!("answers/{slug}.md");
        let fm = Frontmatter {
            title: Some(util::truncate_chars(question, 80)),
            kind: Some("answer".to_string()),
            summary: Some(util::truncate_chars(question, 120)),
            updated: Some(util::today()),
            ..Default::default()
        };
        let body = format!("# {}\n\n{}\n", util::truncate_chars(question, 80), answer);
        util::write_text(&wiki.join(&rel), &frontmatter::render(&fm, &body))?;
        index::rebuild(&wiki)?;
        log::append(&wiki, "query", question, &format!("Filed answer → {rel}"))?;
        saved_page = Some(rel);
    }

    let citations = parse_citations(&answer);
    Ok(AskResult {
        answer,
        citations,
        saved_page,
        cost_usd: outcome.cost_usd,
    })
}

/// All content pages in the wiki (catalog).
pub fn list_pages(ctx: &EngineCtx) -> Vec<index::PageInfo> {
    index::list_pages(&ctx.wiki_dir())
}

/// Read a wiki page by path relative to the wiki dir (sandbox-safe).
pub fn read_page(ctx: &EngineCtx, rel: &str) -> Result<String> {
    let wiki = ctx.wiki_dir();
    let path = crate::agent::sandbox::safe_join(&wiki, rel)?;
    // Defense in depth (mirrors the write sandbox): if the file resolves through
    // a symlink out of the wiki dir, refuse it.
    if let (Ok(base), Ok(real)) = (std::fs::canonicalize(&wiki), std::fs::canonicalize(&path)) {
        if !real.starts_with(&base) {
            bail!("path escapes the wiki directory: {rel}");
        }
    }
    std::fs::read_to_string(&path).with_context(|| format!("reading wiki page {rel}"))
}

/// The overview ("start here") page.
pub fn read_overview(ctx: &EngineCtx) -> Result<String> {
    read_page(ctx, "overview.md")
}

/// A concise status summary (for the MCP `status` tool and web).
pub fn status_text(ctx: &EngineCtx) -> String {
    let wiki = ctx.wiki_dir();
    let mut out = String::new();
    out.push_str(&format!("repository: {}\n", ctx.repo_root.display()));
    out.push_str(&format!(
        "provider:   {} (model: {})\n",
        ctx.cfg.provider.as_str(),
        ctx.cfg.model.as_deref().unwrap_or("default")
    ));
    if wiki.exists() {
        out.push_str(&format!(
            "wiki:       {}/ ({} page(s))\n",
            ctx.cfg.wiki_dir,
            list_pages(ctx).len()
        ));
    } else {
        out.push_str("wiki:       absent (run `hephaes init`)\n");
    }
    let manifest = Manifest::load(&ctx.paths.manifest);
    if !manifest.sources.is_empty() {
        out.push_str(&format!(
            "manifest:   {} source(s), last built at {}\n",
            manifest.sources.len(),
            manifest.last_commit.as_deref().unwrap_or("?")
        ));
        if let (Some(h), Some(last)) = (
            repo::diff::head_commit(&ctx.repo_root),
            &manifest.last_commit,
        ) {
            out.push_str(if &h != last {
                "drift:      HEAD differs; run `hephaes update`\n"
            } else {
                "drift:      up to date with HEAD\n"
            });
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::{PerCommandModels, ProviderId};
    use crate::progress::NoopProgress;
    use crate::testing::MockProvider;

    fn test_cfg() -> Config {
        Config {
            provider: ProviderId::Anthropic,
            model: Some("mock-model".to_string()),
            models: PerCommandModels::default(),
            wiki_dir: "wiki".to_string(),
            ollama_url: "http://localhost:11434".to_string(),
            concurrency: 1,
            include: vec![],
            exclude: vec![],
            max_file_bytes: 1 << 20,
        }
    }

    fn fixture(name: &str) -> PathBuf {
        let dir =
            std::env::temp_dir().join(format!("hephaes-engine-{name}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(dir.join("wiki")).unwrap();
        std::fs::write(
            dir.join("wiki/index.md"),
            "# Index\n\n- [Overview](overview.md) — the project\n",
        )
        .unwrap();
        std::fs::write(
            dir.join("wiki/overview.md"),
            "---\ntitle: Overview\ntype: overview\n---\n\n# Overview\nThe project.\n",
        )
        .unwrap();
        dir
    }

    #[tokio::test]
    async fn ask_with_returns_answer_and_saves_page() {
        let dir = fixture("ask");
        let ctx = EngineCtx::new(dir.clone(), test_cfg());
        let provider = MockProvider::new("Auth lives in src/auth.rs:1.");
        let res = ask_with(
            &ctx,
            &provider,
            "how does auth work?",
            &AskOptions {
                from_source: false,
                save: true,
            },
            &NoopProgress,
        )
        .await
        .unwrap();
        assert_eq!(res.answer, "Auth lives in src/auth.rs:1.");
        let saved = res.saved_page.expect("page saved");
        assert!(saved.starts_with("answers/"));
        assert!(dir.join("wiki").join(&saved).exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn ask_with_errors_when_no_wiki() {
        let dir =
            std::env::temp_dir().join(format!("hephaes-engine-nowiki-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        let ctx = EngineCtx::new(dir.clone(), test_cfg());
        let provider = MockProvider::new("x");
        let err = ask_with(&ctx, &provider, "q", &AskOptions::default(), &NoopProgress)
            .await
            .unwrap_err();
        assert!(err.to_string().contains("no wiki found"), "got: {err}");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn ask_with_rejects_empty_question() {
        let dir = fixture("empty");
        let ctx = EngineCtx::new(dir.clone(), test_cfg());
        let provider = MockProvider::new("x");
        let err = ask_with(
            &ctx,
            &provider,
            "   ",
            &AskOptions::default(),
            &NoopProgress,
        )
        .await
        .unwrap_err();
        assert!(err.to_string().contains("empty question"));
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn list_read_and_sandbox() {
        let dir = fixture("read");
        let ctx = EngineCtx::new(dir.clone(), test_cfg());
        assert!(list_pages(&ctx).iter().any(|p| p.rel == "overview.md"));
        assert!(read_overview(&ctx).unwrap().contains("The project."));
        assert!(read_page(&ctx, "../escape.md").is_err()); // sandbox blocks escape
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[tokio::test]
    async fn ask_with_save_false_keeps_no_page() {
        let dir = fixture("nosave");
        let ctx = EngineCtx::new(dir.clone(), test_cfg());
        let provider = MockProvider::new("answer");
        let res = ask_with(
            &ctx,
            &provider,
            "q",
            &AskOptions {
                from_source: false,
                save: false,
            },
            &NoopProgress,
        )
        .await
        .unwrap();
        assert!(res.saved_page.is_none());
        assert!(!dir.join("wiki/answers").exists());
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn citations_parsed_from_answer() {
        let cites = parse_citations("See [[Auth Module]] and src/auth.rs:42 for details.");
        assert!(cites
            .iter()
            .any(|c| c.kind == CitationKind::Page && c.target == "Auth Module"));
        assert!(cites.iter().any(|c| c.kind == CitationKind::Source
            && c.target == "src/auth.rs"
            && c.line == Some(42)));
    }
}
