use std::collections::{BTreeMap, HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;

use axum::http::StatusCode;
use axum::Json;
use tokio::process::Command;

use super::dto::{
    MaterializedLaunchTarget, PortalLaunchJob, PortalLaunchRequest, PortalLaunchTarget,
};
use super::errors::{bad_request_error, internal_error};
use super::query::ErrorResponse;
use super::run_analysis::scan_runs;
use super::state::PortalState;
use super::util::{portal_now_rfc3339, portal_unique_id, redacted_string};

const MAX_LAUNCH_JOB_LOG_BYTES: usize = 256 * 1024;
const MAX_COMPLETED_LAUNCH_JOBS: usize = 200;

pub(super) async fn create_launch_job(
    state: &Arc<PortalState>,
    request: PortalLaunchRequest,
) -> Result<PortalLaunchJob, (StatusCode, Json<ErrorResponse>)> {
    validate_launch_request(&request)?;

    let job_id = portal_unique_id("job");
    let started_at = portal_now_rfc3339();
    let before_paths = known_run_paths(&state.run_dir).map_err(internal_error)?;
    let launch_env = validated_env_overrides(request.env.as_ref())?;
    let materialized =
        materialize_launch_target(&state.run_dir, &state.workspace_root, &job_id, request)
            .map_err(internal_error)?;
    let transcript_path = materialized.workspace_dir.as_ref().map(|dir| {
        dir.join("run-llm")
            .join("llm_transcript.jsonl")
            .display()
            .to_string()
    });

    let job = PortalLaunchJob {
        id: job_id.clone(),
        mode: materialized.mode.clone(),
        target_label: materialized.target_label.clone(),
        status: "running".to_string(),
        started_at,
        finished_at: None,
        exit_code: None,
        logs: String::new(),
        discovered_run_paths: Vec::new(),
        workspace_dir: materialized
            .workspace_dir
            .as_ref()
            .map(|path| path.display().to_string()),
        transcript_path,
    };
    state
        .launch_jobs
        .lock()
        .await
        .insert(job_id.clone(), job.clone());

    let jobs = state.launch_jobs.clone();
    let launch_program = state.launch_program.clone();
    let run_dir = state.run_dir.clone();
    let workspace_root = state.workspace_root.clone();
    tokio::spawn(async move {
        let output = Command::new(&launch_program)
            .arg("run")
            .arg(&materialized.launch_file)
            .current_dir(&workspace_root)
            .envs(build_launch_env(
                materialized.workspace_dir.as_deref(),
                &launch_env,
            ))
            .output()
            .await;

        let mut jobs = jobs.lock().await;
        if let Some(job) = jobs.get_mut(&job_id) {
            match output {
                Ok(output) => {
                    job.logs = launch_output_logs(&output.stdout, &output.stderr);
                    job.exit_code = output.status.code();
                    job.status = if output.status.success() {
                        "completed".to_string()
                    } else {
                        "failed".to_string()
                    };
                    job.finished_at = Some(portal_now_rfc3339());
                    job.discovered_run_paths =
                        discovered_run_paths(&run_dir, &before_paths).unwrap_or_default();
                }
                Err(error) => {
                    job.status = "failed".to_string();
                    job.finished_at = Some(portal_now_rfc3339());
                    job.logs = format!("failed to start harn run: {error}");
                }
            }
            prune_completed_launch_jobs(&mut jobs);
        }
    });

    Ok(job)
}

pub(super) async fn create_trigger_replay_job(
    state: &Arc<PortalState>,
    event_id: &str,
) -> Result<PortalLaunchJob, (StatusCode, Json<ErrorResponse>)> {
    let event_id = event_id.trim();
    if event_id.is_empty() {
        return Err(bad_request_error("event_id is required"));
    }

    let job_id = portal_unique_id("job");
    let started_at = portal_now_rfc3339();
    let before_paths = known_run_paths(&state.run_dir).map_err(internal_error)?;
    let job = PortalLaunchJob {
        id: job_id.clone(),
        mode: "trigger_replay".to_string(),
        target_label: format!("trigger replay {event_id}"),
        status: "running".to_string(),
        started_at,
        finished_at: None,
        exit_code: None,
        logs: String::new(),
        discovered_run_paths: Vec::new(),
        workspace_dir: None,
        transcript_path: None,
    };
    state
        .launch_jobs
        .lock()
        .await
        .insert(job_id.clone(), job.clone());

    let jobs = state.launch_jobs.clone();
    let launch_program = state.launch_program.clone();
    let run_dir = state.run_dir.clone();
    let workspace_root = state.workspace_root.clone();
    let event_id = event_id.to_string();
    tokio::spawn(async move {
        let output = Command::new(&launch_program)
            .arg("trigger")
            .arg("replay")
            .arg(&event_id)
            .current_dir(&workspace_root)
            .output()
            .await;

        let mut jobs = jobs.lock().await;
        if let Some(job) = jobs.get_mut(&job_id) {
            match output {
                Ok(output) => {
                    job.logs = launch_output_logs(&output.stdout, &output.stderr);
                    job.exit_code = output.status.code();
                    job.status = if output.status.success() {
                        "completed".to_string()
                    } else {
                        "failed".to_string()
                    };
                    job.finished_at = Some(portal_now_rfc3339());
                    job.discovered_run_paths =
                        discovered_run_paths(&run_dir, &before_paths).unwrap_or_default();
                }
                Err(error) => {
                    job.status = "failed".to_string();
                    job.finished_at = Some(portal_now_rfc3339());
                    job.logs = format!("failed to start trigger replay: {error}");
                }
            }
            prune_completed_launch_jobs(&mut jobs);
        }
    });

    Ok(job)
}

pub(super) fn launch_output_logs(stdout: &[u8], stderr: &[u8]) -> String {
    let mut bytes = Vec::with_capacity(stdout.len().saturating_add(stderr.len()).saturating_add(1));
    bytes.extend_from_slice(stdout);
    if !stderr.is_empty() {
        if !bytes.is_empty() {
            bytes.push(b'\n');
        }
        bytes.extend_from_slice(stderr);
    }
    let truncated = tail_bytes(&bytes, MAX_LAUNCH_JOB_LOG_BYTES);
    let prefix = if truncated.len() < bytes.len() {
        format!("[truncated to last {MAX_LAUNCH_JOB_LOG_BYTES} bytes]\n")
    } else {
        String::new()
    };
    let logs = String::from_utf8_lossy(truncated);
    format!("{prefix}{}", redacted_string(&logs))
}

fn tail_bytes(bytes: &[u8], max: usize) -> &[u8] {
    if bytes.len() <= max {
        bytes
    } else {
        &bytes[bytes.len() - max..]
    }
}

pub(super) fn prune_completed_launch_jobs(jobs: &mut HashMap<String, PortalLaunchJob>) {
    let mut completed = jobs
        .iter()
        .filter(|(_, job)| job.finished_at.is_some())
        .map(|(id, job)| (job.finished_at.clone().unwrap_or_default(), id.clone()))
        .collect::<Vec<_>>();
    if completed.len() <= MAX_COMPLETED_LAUNCH_JOBS {
        return;
    }
    completed.sort();
    let remove_count = completed.len() - MAX_COMPLETED_LAUNCH_JOBS;
    for (_, id) in completed.into_iter().take(remove_count) {
        jobs.remove(&id);
    }
}

pub(super) fn validate_launch_request(
    request: &PortalLaunchRequest,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
    let has_file = request
        .file_path
        .as_ref()
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false);
    let has_source = request
        .source
        .as_ref()
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false);
    let has_playground = request
        .task
        .as_ref()
        .map(|value| !value.trim().is_empty())
        .unwrap_or(false);
    let count = [has_file, has_source, has_playground]
        .into_iter()
        .filter(|value| *value)
        .count();
    if count != 1 {
        return Err(bad_request_error(
            "launch requires exactly one of file_path, source, or task",
        ));
    }
    Ok(())
}

pub(super) fn validated_env_overrides(
    env: Option<&BTreeMap<String, String>>,
) -> Result<BTreeMap<String, String>, (StatusCode, Json<ErrorResponse>)> {
    let mut validated = BTreeMap::new();
    if let Some(env) = env {
        for (key, value) in env {
            if !valid_env_key(key) {
                return Err(bad_request_error(format!(
                    "invalid env key '{key}'; use uppercase shell-style names that start with a letter or underscore"
                )));
            }
            if value.contains('\0') {
                return Err(bad_request_error(format!(
                    "env value for '{key}' contains a NUL byte"
                )));
            }
            if value.len() > 16_384 {
                return Err(bad_request_error(format!(
                    "env value for '{key}' is too large"
                )));
            }
            validated.insert(key.clone(), value.clone());
        }
    }
    Ok(validated)
}

fn valid_env_key(key: &str) -> bool {
    let mut chars = key.chars();
    let Some(first) = chars.next() else {
        return false;
    };
    (first.is_ascii_uppercase() || first == '_')
        && chars.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_')
}

pub(super) fn build_launch_env(
    workspace_dir: Option<&Path>,
    overrides: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
    let mut env = overrides.clone();
    if let Some(workspace_dir) = workspace_dir {
        env.insert(
            "HARN_LLM_TRANSCRIPT_DIR".to_string(),
            workspace_dir.join("run-llm").display().to_string(),
        );
    }
    env
}

pub(super) fn materialize_launch_target(
    run_dir: &Path,
    workspace_root: &Path,
    job_id: &str,
    request: PortalLaunchRequest,
) -> Result<MaterializedLaunchTarget, String> {
    if let Some(file_path) = request.file_path.filter(|value| !value.trim().is_empty()) {
        let path = resolve_workspace_file(workspace_root, &file_path)?;
        return Ok(MaterializedLaunchTarget {
            mode: "file".to_string(),
            target_label: file_path,
            launch_file: path,
            workspace_dir: None,
        });
    }

    if let Some(source) = request.source.filter(|value| !value.trim().is_empty()) {
        let workspace_dir = launch_workspace_dir(run_dir, job_id)?;
        let launch_file = workspace_dir.join("workflow.harn");
        fs::write(&launch_file, source)
            .map_err(|error| format!("failed to write temp source file: {error}"))?;
        write_launch_metadata(
            &workspace_dir,
            &serde_json::json!({
                "mode": "source",
                "source_path": launch_file.display().to_string(),
            }),
        )?;
        return Ok(MaterializedLaunchTarget {
            mode: "source".to_string(),
            target_label: launch_file.display().to_string(),
            launch_file,
            workspace_dir: Some(workspace_dir),
        });
    }

    let task = request
        .task
        .filter(|value| !value.trim().is_empty())
        .ok_or_else(|| "playground task is required".to_string())?;
    let workspace_dir = launch_workspace_dir(run_dir, job_id)?;
    let source = build_playground_source(
        &workspace_dir,
        &task,
        request.provider.as_deref(),
        request.model.as_deref(),
    );
    let launch_file = workspace_dir.join("workflow.harn");
    fs::write(&launch_file, source)
        .map_err(|error| format!("failed to write playground source file: {error}"))?;
    fs::write(workspace_dir.join("task.txt"), &task)
        .map_err(|error| format!("failed to write playground task file: {error}"))?;
    write_launch_metadata(
        &workspace_dir,
        &serde_json::json!({
            "mode": "playground",
            "task": task,
            "provider": request.provider.as_deref(),
            "model": request.model.as_deref(),
            "workflow_path": launch_file.display().to_string(),
            "run_path": workspace_dir.join("run.json").display().to_string(),
            "transcript_path": workspace_dir.join("run-llm").join("llm_transcript.jsonl").display().to_string(),
        }),
    )?;
    Ok(MaterializedLaunchTarget {
        mode: "playground".to_string(),
        target_label: format!("playground: {task}"),
        launch_file,
        workspace_dir: Some(workspace_dir),
    })
}

fn launch_workspace_dir(run_dir: &Path, job_id: &str) -> Result<PathBuf, String> {
    let dir = run_dir.join("playground").join(job_id);
    fs::create_dir_all(&dir).map_err(|error| {
        format!(
            "failed to create launch workspace {}: {error}",
            dir.display()
        )
    })?;
    Ok(dir)
}

fn write_launch_metadata(workspace_dir: &Path, payload: &serde_json::Value) -> Result<(), String> {
    let content = serde_json::to_string_pretty(payload)
        .map_err(|error| format!("failed to encode launch metadata: {error}"))?;
    fs::write(workspace_dir.join("launch.json"), content)
        .map_err(|error| format!("failed to write launch metadata: {error}"))
}

fn resolve_workspace_file(workspace_root: &Path, relative_path: &str) -> Result<PathBuf, String> {
    let path = Path::new(relative_path);
    if path.is_absolute() {
        return Err("file_path must be relative to the current workspace".to_string());
    }
    if path.components().any(|component| {
        matches!(
            component,
            std::path::Component::ParentDir
                | std::path::Component::RootDir
                | std::path::Component::Prefix(_)
        )
    }) {
        return Err("file_path must stay inside the current workspace".to_string());
    }
    let resolved = workspace_root.join(path);
    if !resolved.exists() {
        return Err(format!("file not found: {relative_path}"));
    }
    let canonical_root = workspace_root
        .canonicalize()
        .map_err(|error| format!("failed to resolve workspace root: {error}"))?;
    let canonical_resolved = resolved
        .canonicalize()
        .map_err(|error| format!("failed to resolve file path: {error}"))?;
    if !canonical_resolved.starts_with(&canonical_root) {
        return Err("file_path must stay inside the current workspace".to_string());
    }
    Ok(canonical_resolved)
}

fn build_playground_source(
    workspace_dir: &Path,
    task: &str,
    provider: Option<&str>,
    model: Option<&str>,
) -> String {
    let persist_path = workspace_dir.join("run.json");
    let provider_line = provider
        .filter(|value| !value.trim().is_empty())
        .map(|value| format!("provider: {value:?},"))
        .unwrap_or_default();
    let model_line = model
        .filter(|value| !value.trim().is_empty())
        .map(|value| format!("model: {value:?},"))
        .unwrap_or_default();

    format!(
        r#"pipeline main() {{
  let flow = workflow_graph(
    {{
    name: "portal_playground",
    entry: "act",
    nodes: {{
    act: {{
    kind: "stage",
    mode: "llm",
    model_policy: {{{provider_line}{model_line}}},
    output_contract: {{output_kinds: ["summary"]}},
  }},
  }},
    edges: [],
  }},
  )
  let seed = artifact({{kind: "summary", text: "Playground seed context", relevance: 0.5}})
  let workspace_note = artifact({{
    kind: "workspace_file",
    title: "task.txt",
    text: {task:?},
    relevance: 0.9,
  }})
  let result = workflow_execute(
    {task:?},
    flow,
    [seed, workspace_note],
    {{max_steps: 4, persist_path: {:?}}},
  )
  __io_println(result?.status)
  __io_println(result?.run?.persisted_path)
}}"#,
        persist_path.display().to_string()
    )
}

fn known_run_paths(run_dir: &Path) -> Result<HashSet<String>, String> {
    Ok(scan_runs(run_dir)?
        .into_iter()
        .map(|run| run.path)
        .collect::<HashSet<_>>())
}

fn discovered_run_paths(
    run_dir: &Path,
    before_paths: &HashSet<String>,
) -> Result<Vec<String>, String> {
    let mut paths = scan_runs(run_dir)?
        .into_iter()
        .map(|run| run.path)
        .filter(|path| !before_paths.contains(path))
        .collect::<Vec<_>>();
    paths.sort();
    Ok(paths)
}

pub(super) fn scan_launch_targets(
    workspace_root: &Path,
) -> Result<Vec<PortalLaunchTarget>, String> {
    let groups = [
        ("examples", workspace_root.join("examples")),
        ("conformance", workspace_root.join("conformance/tests")),
    ];
    let mut targets = Vec::new();
    for (group, root) in groups {
        collect_launch_targets(workspace_root, &root, group, &mut targets)?;
    }
    targets.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(targets)
}

fn collect_launch_targets(
    workspace_root: &Path,
    current: &Path,
    group: &str,
    out: &mut Vec<PortalLaunchTarget>,
) -> Result<(), String> {
    if !current.exists() {
        return Ok(());
    }
    for entry in fs::read_dir(current)
        .map_err(|error| format!("failed to read {}: {error}", current.display()))?
    {
        let entry =
            entry.map_err(|error| format!("failed to iterate {}: {error}", current.display()))?;
        let path = entry.path();
        if path.is_dir() {
            collect_launch_targets(workspace_root, &path, group, out)?;
        } else if path.extension().is_some_and(|ext| ext == "harn") {
            let relative = path
                .strip_prefix(workspace_root)
                .map_err(|error| format!("failed to relativize {}: {error}", path.display()))?;
            out.push(PortalLaunchTarget {
                path: logical_path(relative),
                group: group.to_string(),
            });
        }
    }
    Ok(())
}

fn logical_path(path: &Path) -> String {
    path.components()
        .filter_map(|component| match component {
            Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
            Component::CurDir => None,
            Component::ParentDir => Some("..".to_string()),
            Component::RootDir | Component::Prefix(_) => {
                Some(component.as_os_str().to_string_lossy().into_owned())
            }
        })
        .collect::<Vec<_>>()
        .join("/")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn logical_path_uses_slashes_for_native_launch_paths() {
        let path = Path::new("examples").join("nested").join("demo.harn");

        assert_eq!(logical_path(&path), "examples/nested/demo.harn");
    }
}
