//! Ticket assistant infrastructure adapters.

use std::sync::Arc;

use application::McpRuntime;
use async_trait::async_trait;
use domain::ports::SessionPlan;
use domain::profile::{McpConfigStrategy, StructuredAdapter};
use domain::{
    AgentProfile, AgentRuntime, AssistantContextError, AssistantContextProvider,
    ContextInjectionPlan, FileSystem, FsError, Issue, IssueRef, MarkdownDoc, McpServerWiring,
    PreparedContext, Project, ProjectPath, RemotePath, RuntimeError, StructuredSessionEnvironment,
    StructuredSessionEnvironmentPreparer,
};
use serde_json::{json, Map, Value};

const ASSISTANT_DIR: &str = "assistant";
const TICKET_ASSISTANT_FILE: &str = "ticket-assistant.md";
const DEFAULT_TICKET_ASSISTANT_CONTEXT: &str = include_str!("default_ticket_assistant.md");

/// Resolves the live MCP runtime facts for a ticket assistant requester.
pub type TicketAssistantMcpRuntimeResolver =
    dyn Fn(&Project, &str) -> Option<McpRuntime> + Send + Sync;

/// File-backed provider for the IdeA-owned ticket assistant context.
#[derive(Clone)]
pub struct FsAssistantContextStore {
    fs: Arc<dyn FileSystem>,
    app_data_dir: String,
}

impl FsAssistantContextStore {
    /// Builds the store from an injected filesystem and app-data directory.
    #[must_use]
    pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
        Self {
            fs,
            app_data_dir: app_data_dir.into(),
        }
    }

    fn join(&self, rel: &str) -> String {
        let base = self.app_data_dir.trim_end_matches(['/', '\\']);
        format!("{base}/{rel}")
    }

    fn context_path(&self) -> RemotePath {
        RemotePath::new(self.join(&format!("{ASSISTANT_DIR}/{TICKET_ASSISTANT_FILE}")))
    }

    async fn read_base_context(&self) -> Result<String, AssistantContextError> {
        let path = self.context_path();
        match self.fs.read(&path).await {
            Ok(bytes) => {
                String::from_utf8(bytes).map_err(|e| AssistantContextError::Store(e.to_string()))
            }
            Err(FsError::NotFound(_)) => Ok(DEFAULT_TICKET_ASSISTANT_CONTEXT.to_owned()),
            Err(e) => Err(AssistantContextError::Store(e.to_string())),
        }
    }
}

#[async_trait]
impl AssistantContextProvider for FsAssistantContextStore {
    async fn prepare_ticket_assistant_context(
        &self,
        project: &Project,
        issue: &Issue,
    ) -> Result<PreparedContext, AssistantContextError> {
        let mut content = self.read_base_context().await?;
        content.push_str("\n\n## Bound Ticket\n\n");
        content.push_str(&format!("- Ref: {}\n", issue.reference()));
        content.push_str(&format!("- Title: {}\n", issue.title));
        content.push_str("\n### Description\n\n");
        content.push_str(issue.description.as_str());
        content.push_str("\n\n### Carnet\n\n");
        content.push_str(issue.carnet.as_str());
        content.push('\n');

        Ok(PreparedContext {
            content: MarkdownDoc::new(content),
            relative_path: TICKET_ASSISTANT_FILE.to_owned(),
            project_root: project.root.as_str().to_owned(),
        })
    }
}

/// File-backed environment preparer for structured ticket assistant sessions.
#[derive(Clone)]
pub struct TicketAssistantEnvironmentPreparer {
    fs: Arc<dyn FileSystem>,
    app_data_dir: String,
    runtime: Arc<dyn AgentRuntime>,
    mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
}

impl TicketAssistantEnvironmentPreparer {
    /// Builds the preparer from filesystem, app-data dir, runtime and MCP resolver.
    #[must_use]
    pub fn new(
        fs: Arc<dyn FileSystem>,
        app_data_dir: impl Into<String>,
        runtime: Arc<dyn AgentRuntime>,
        mcp_runtime: Arc<TicketAssistantMcpRuntimeResolver>,
    ) -> Self {
        Self {
            fs,
            app_data_dir: app_data_dir.into(),
            runtime,
            mcp_runtime,
        }
    }

    fn run_dir(&self, project: &Project, issue_ref: IssueRef) -> Result<ProjectPath, RuntimeError> {
        let base = self.app_data_dir.trim_end_matches(['/', '\\']);
        let project_id = project.id.as_uuid().simple();
        let issue_number = issue_ref.number().get();
        ProjectPath::new(format!(
            "{base}/{ASSISTANT_DIR}/tickets/{project_id}/{issue_number}"
        ))
        .map_err(|e| RuntimeError::Invocation(e.to_string()))
    }

    async fn create_dir(&self, path: &str) -> Result<(), RuntimeError> {
        self.fs
            .create_dir_all(&RemotePath::new(path.to_owned()))
            .await
            .map_err(|e| RuntimeError::Invocation(e.to_string()))
    }

    async fn write_file(&self, path: &str, bytes: &[u8]) -> Result<(), RuntimeError> {
        if let Some(parent) = parent_abs(path) {
            self.create_dir(parent).await?;
        }
        self.fs
            .write(&RemotePath::new(path.to_owned()), bytes)
            .await
            .map_err(|e| RuntimeError::Invocation(e.to_string()))
    }

    async fn read_optional(&self, path: &str) -> Result<Option<String>, RuntimeError> {
        match self.fs.read(&RemotePath::new(path.to_owned())).await {
            Ok(bytes) => String::from_utf8(bytes)
                .map(Some)
                .map_err(|e| RuntimeError::Invocation(e.to_string())),
            Err(FsError::NotFound(_)) => Ok(None),
            Err(e) => Err(RuntimeError::Invocation(e.to_string())),
        }
    }

    async fn materialise_context(
        &self,
        plan: Option<ContextInjectionPlan>,
        cwd: &ProjectPath,
        prepared: &PreparedContext,
        env: &mut Vec<(String, String)>,
    ) -> Result<(), RuntimeError> {
        match plan {
            Some(ContextInjectionPlan::File { target }) => {
                self.write_file(&join(cwd, &target), prepared.content.as_str().as_bytes())
                    .await?;
            }
            Some(ContextInjectionPlan::Env { var }) => {
                let path = join(cwd, &prepared.relative_path);
                self.write_file(&path, prepared.content.as_str().as_bytes())
                    .await?;
                env.push((var, path));
            }
            Some(ContextInjectionPlan::Args { .. }) | Some(ContextInjectionPlan::Stdin) | None => {
                let path = join(cwd, &prepared.relative_path);
                self.write_file(&path, prepared.content.as_str().as_bytes())
                    .await?;
            }
        }
        Ok(())
    }

    async fn materialise_mcp(
        &self,
        project: &Project,
        profile: &AgentProfile,
        cwd: &ProjectPath,
        requester: &str,
        env: &mut Vec<(String, String)>,
    ) -> Result<(), RuntimeError> {
        let Some(mcp) = &profile.mcp else {
            return Ok(());
        };
        let runtime = (self.mcp_runtime)(project, requester);
        match &mcp.config {
            McpConfigStrategy::ConfigFile { target } => {
                let declaration = mcp_server_wiring(mcp.transport, runtime.as_ref()).to_mcp_json();
                self.write_file(&join(cwd, target), declaration.as_bytes())
                    .await?;
            }
            McpConfigStrategy::TomlConfigHome { target, home_env } => {
                let path = join(cwd, target);
                let declaration =
                    mcp_server_wiring(mcp.transport, runtime.as_ref()).to_config_toml();
                let existing = self.read_optional(&path).await?;
                let rendered = codex_config_toml(
                    existing.as_deref(),
                    &declaration,
                    cwd.as_str(),
                    project.root.as_str(),
                );
                self.write_file(&path, rendered.as_bytes()).await?;
                env.push((home_env.clone(), parent_dir(cwd, target)));
            }
            McpConfigStrategy::OpenCodeConfig { target } => {
                if profile.structured_adapter != Some(StructuredAdapter::OpenCode) {
                    return Ok(());
                }
                let Some(opencode) = profile.opencode.as_ref() else {
                    return Ok(());
                };
                let config_path = join(cwd, target);
                let opencode_home = join(cwd, ".opencode");
                let xdg_config = format!("{opencode_home}/config");
                let xdg_data = format!("{opencode_home}/data");
                let xdg_cache = format!("{opencode_home}/cache");
                for dir in [&opencode_home, &xdg_config, &xdg_data, &xdg_cache] {
                    self.create_dir(dir).await?;
                }
                let body = opencode_config_json(opencode, project.root.as_str(), runtime.as_ref())
                    .to_string();
                self.write_file(&config_path, body.as_bytes()).await?;
                env.extend([
                    ("OPENCODE_CONFIG".to_owned(), config_path),
                    ("HOME".to_owned(), opencode_home),
                    ("XDG_CONFIG_HOME".to_owned(), xdg_config),
                    ("XDG_DATA_HOME".to_owned(), xdg_data),
                    ("XDG_CACHE_HOME".to_owned(), xdg_cache),
                    ("OPENCODE_DISABLE_AUTOUPDATE".to_owned(), "1".to_owned()),
                ]);
            }
            McpConfigStrategy::Flag { flag } => {
                env.push((flag.clone(), cwd.as_str().to_owned()));
            }
            McpConfigStrategy::Env { var } => {
                env.push((var.clone(), cwd.as_str().to_owned()));
            }
        }
        Ok(())
    }
}

#[async_trait]
impl StructuredSessionEnvironmentPreparer for TicketAssistantEnvironmentPreparer {
    async fn prepare_ticket_assistant(
        &self,
        project: &Project,
        issue_ref: IssueRef,
        profile: &AgentProfile,
        prepared: &PreparedContext,
        requester: &str,
    ) -> Result<StructuredSessionEnvironment, RuntimeError> {
        let run_dir = self.run_dir(project, issue_ref)?;
        self.create_dir(run_dir.as_str()).await?;
        let spec =
            self.runtime
                .prepare_invocation(profile, prepared, &run_dir, &SessionPlan::None)?;
        let mut env = spec.env;
        self.materialise_context(spec.context_plan, &spec.cwd, prepared, &mut env)
            .await?;
        self.materialise_mcp(project, profile, &spec.cwd, requester, &mut env)
            .await?;
        Ok(StructuredSessionEnvironment { cwd: spec.cwd, env })
    }
}

fn mcp_server_wiring(
    transport: domain::profile::McpTransport,
    runtime: Option<&McpRuntime>,
) -> McpServerWiring {
    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                rt.endpoint.clone(),
                "--project".to_owned(),
                rt.project_id.clone(),
                "--requester".to_owned(),
                rt.requester.clone(),
            ],
        ),
        None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
    };
    McpServerWiring::new(command, args, transport)
}

fn opencode_config_json(
    config: &domain::profile::OpenCodeConfig,
    project_root: &str,
    runtime: Option<&McpRuntime>,
) -> Value {
    let model = config.model.as_str();
    let opencode_model = format!("llamacpp/{model}");
    let mut root = Map::new();
    root.insert(
        "$schema".to_owned(),
        Value::String("https://opencode.ai/config.json".to_owned()),
    );
    root.insert("model".to_owned(), Value::String(opencode_model.clone()));

    let mut options = Map::new();
    options.insert("baseURL".to_owned(), Value::String(config.base_url.clone()));
    if let Some(api_key) = config.api_key.as_ref() {
        options.insert("apiKey".to_owned(), Value::String(api_key.clone()));
    }

    root.insert(
        "provider".to_owned(),
        json!({
            "llamacpp": {
                "npm": "@ai-sdk/openai-compatible",
                "name": "llama.cpp",
                "options": options,
                "models": {
                    model: {
                        "name": opencode_model,
                        "tool_call": true,
                        "reasoning": config.reasoning_enabled(),
                        "attachment": config.attachment_enabled()
                    }
                }
            }
        }),
    );

    let (command, args) = match runtime {
        Some(rt) => (
            rt.exe.clone(),
            vec![
                "mcp-server".to_owned(),
                "--endpoint".to_owned(),
                rt.endpoint.clone(),
                "--project".to_owned(),
                rt.project_id.clone(),
                "--requester".to_owned(),
                rt.requester.clone(),
            ],
        ),
        None => ("idea".to_owned(), vec!["mcp-server".to_owned()]),
    };
    let command_array = std::iter::once(command)
        .chain(args)
        .map(Value::String)
        .collect::<Vec<_>>();
    root.insert(
        "mcp".to_owned(),
        json!({
            "idea": {
                "type": "local",
                "command": command_array,
                "cwd": project_root,
                "enabled": true,
                "timeout": 15000
            }
        }),
    );
    root.insert(
        "permission".to_owned(),
        json!({
            "bash": "ask",
            "edit": "ask"
        }),
    );
    root.insert(
        "disabled_providers".to_owned(),
        json!(["anthropic", "openai", "gemini", "ollama"]),
    );
    Value::Object(root)
}

fn codex_config_toml(
    existing: Option<&str>,
    mcp_declaration: &str,
    run_dir: &str,
    project_root: &str,
) -> String {
    let mut text = replace_toml_table_block(
        existing.unwrap_or_default(),
        "mcp_servers.idea",
        mcp_declaration.trim_end(),
    );
    text = ensure_codex_project_trust(&text, run_dir);
    text = ensure_codex_project_trust(&text, project_root);
    if !text.ends_with('\n') {
        text.push('\n');
    }
    text
}

fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
    let header = format!("[{table}]");
    let mut out = Vec::new();
    let mut skipping = false;
    let mut inserted = false;

    for line in existing.lines() {
        let trimmed = line.trim();
        if trimmed == header {
            if !inserted {
                out.extend(replacement.lines().map(ToOwned::to_owned));
                inserted = true;
            }
            skipping = true;
            continue;
        }
        if skipping && trimmed.starts_with('[') && trimmed.ends_with(']') {
            skipping = false;
        }
        if !skipping {
            out.push(line.to_owned());
        }
    }

    if !inserted {
        if !out.is_empty() && !out.last().is_some_and(|line| line.is_empty()) {
            out.push(String::new());
        }
        out.extend(replacement.lines().map(ToOwned::to_owned));
    }

    out.join("\n")
}

fn ensure_codex_project_trust(existing: &str, path: &str) -> String {
    if path.is_empty() {
        return existing.to_owned();
    }
    let header = format!(r#"[projects.{}]"#, toml_quoted(path));
    if existing.lines().any(|line| line.trim() == header) {
        return existing.to_owned();
    }
    let mut text = existing.trim_end().to_owned();
    if !text.is_empty() {
        text.push_str("\n\n");
    }
    text.push_str(&header);
    text.push_str("\ntrust_level = \"trusted\"\n");
    text
}

fn toml_quoted(value: &str) -> String {
    let mut out = String::with_capacity(value.len() + 2);
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
            c => out.push(c),
        }
    }
    out.push('"');
    out
}

fn join(base: &ProjectPath, rel: &str) -> String {
    let base = base.as_str().trim_end_matches(['/', '\\']);
    format!("{base}/{rel}")
}

fn parent_abs(path: &str) -> Option<&str> {
    path.rsplit_once(['/', '\\'])
        .map(|(parent, _)| parent)
        .filter(|parent| !parent.is_empty())
}

fn parent_dir(base: &ProjectPath, rel: &str) -> String {
    let full = join(base, rel);
    match full.rsplit_once(['/', '\\']) {
        Some((parent, _)) if !parent.is_empty() => parent.to_owned(),
        _ => base.as_str().trim_end_matches(['/', '\\']).to_owned(),
    }
}
