mod cmd;
mod config_pipeline;
mod ctx;
mod editor;
mod error;
mod format;
mod output;
mod parser;
mod render;
mod schema;
mod session;
mod shared;
mod signals;
mod timer;

use std::{
    env, fmt,
    io::{self, IsTerminal as _, Write as _, stderr, stdout},
    num::{self, NonZeroUsize},
    process::ExitCode,
    str::FromStr,
    sync::{
        Arc, Mutex,
        atomic::{AtomicUsize, Ordering},
    },
    thread,
    time::Duration,
};

use camino::{FromPathBufError, Utf8PathBuf, absolute_utf8};
use camino_tempfile::NamedUtf8TempFile;
use clap::{
    ArgAction, Parser,
    builder::{BoolValueParser, TypedValueParser as _},
};
use cmd::Commands;
use comfy_table::{Cell, CellAlignment, Row};
use crossterm::style::Stylize as _;
use ctx::{Ctx, IntoPartialAppConfig};
use error::{Error, Result};
use jp_config::{
    PartialAppConfig,
    assignment::KvAssignment,
    fs::user_global_config_dir,
    util::{
        build, load_envs, load_partial_at_path, load_partial_at_path_recursive,
        load_partials_with_inheritance,
    },
};
use jp_printer::{OutputFormat, Printer};
use jp_storage::backend::{FsStorageBackend, NullLockBackend, NullPersistBackend};
use jp_term::table::{details, details_markdown};
use jp_workspace::{Workspace, user_data_dir};
use relative_path::RelativePath;
use serde_json::Value;
use tokio::{
    runtime::{self, Runtime},
    sync::broadcast,
    task::JoinHandle,
};
use tokio_util::sync::CancellationToken;
use tracing::{debug, info, trace, warn};

use crate::{
    cmd::{
        plugin::dispatch::{describe_plugin, discover_plugins},
        target::resolve_request,
    },
    config_pipeline::ConfigPipeline,
    signals::SignalTo,
    timer::spawn_line_timer,
};

static WORKER_THREADS: AtomicUsize = AtomicUsize::new(0);

const DEFAULT_STORAGE_DIR: &str = ".jp";

#[expect(dead_code)]
const DEFAULT_VARIABLE_PREFIX: &str = "JP_";

/// The prefix used to parse a CLI argument as a path instead of a string.
const PATH_STRING_PREFIX: char = '@';

// Jean Pierre's LLM Toolkit.
#[derive(Parser)]
#[command(name = "jp", author, version, long_version = env!("LONG_VERSION"), about, long_about = None)]
struct Cli {
    #[command(flatten, next_help_heading = "Global Options")]
    globals: Globals,

    #[command(flatten)]
    root: RootOpts,

    #[command(subcommand, next_help_heading = "Options")]
    command: Commands,
}

/// The root options for the CLI.
///
/// These options are only available at the root level, e.g. `jp --foo` but not
/// `jp query --foo`.
#[derive(Parser)]
pub struct RootOpts {
    /// Number of threads to use for processing (default is number of available
    /// cores)
    #[arg(short = 't', long = "threads")]
    pub threads: Option<NonZeroUsize>,
}

#[derive(Debug, Default, clap::Args)]
struct Globals {
    /// Override a configuration value for the duration of the command.
    #[arg(
        short = 'c',
        long = "cfg",
        global = true,
        action = ArgAction::Append,
        value_name = "KEY=VALUE",
        value_parser = KeyValueOrPath::from_str,
    )]
    config: Vec<KeyValueOrPath>,

    /// Increase verbosity of logging.
    ///
    /// Can be specified multiple times to increase verbosity.
    ///
    /// Defaults to printing "error" messages.
    /// For each increase in verbosity, the log level is set to "warn", "info",
    /// "debug", and "trace" respectively.
    #[arg(short = 'v', long, global = true, action = ArgAction::Count)]
    verbose: u8,

    /// Suppress all output, including errors.
    #[arg(short = 'q', long, global = true)]
    quiet: bool,

    /// The output format.
    #[arg(
        short = 'F',
        long = "format",
        global = true,
        value_enum,
        default_value_t = CliFormat::Auto,
    )]
    format: CliFormat,

    /// Persist modified state to disk.
    ///
    /// This is enabled by default, but can be disabled to debug certain
    /// actions.
    /// It is also useful to send a query to the assistant, without adding that
    /// query to the conversation history.
    #[arg(
        short = '!',
        long = "no-persist",
        visible_short_alias = 'P',
        global = true,
        default_value_t = false,
        value_parser = BoolValueParser::new().map(|v| !v),
        help = "Disable persistence for the duration of the command.",
    )]
    persist: bool,

    /// The workspace to use for the command.
    ///
    /// This can be either a path to a workspace directory, or a workspace ID.
    #[arg(short = 'w', long, global = true)]
    workspace: Option<WorkspaceIdOrPath>,

    /// The format of the log output written to stderr.
    ///
    /// Defaults to "text" when stderr is a terminal, and "json" when stderr is
    /// redirected to a file or pipe.
    /// Only takes effect when tracing is written to stderr (via `-v` or
    /// `--log-file=-`).
    #[arg(long, global = true, value_enum, default_value_t = LogFormat::Auto)]
    log_format: LogFormat,

    /// Write tracing logs to an additional destination.
    ///
    /// Use `-` to write to stderr.
    /// Tracing is always written to a log file regardless of this flag.
    #[arg(long, global = true, value_name = "PATH")]
    log_file: Option<String>,

    /// Filter log output by target and level.
    ///
    /// Accepts a comma-separated list of `target=level` pairs.
    /// Levels are one of: off, error, warn, info, debug, trace.
    /// Targets match path prefixes.
    ///
    /// Examples: --log=tool::stderr=trace Show stderr output from local tools.
    /// --log=mcp::stderr=debug Show stderr from MCP servers.
    /// --log='jp\_llm=trace,plugin=off' Trace jp\_llm internals, silence
    /// plugins.
    ///
    /// Composes with `-v`: verbosity sets the baseline for jp's own modules;
    /// `--log` adds or overrides specific targets.
    /// Passing `--log` without `-v` still enables stderr log output.
    #[allow(clippy::doc_markdown)]
    #[arg(long, global = true, value_name = "DIRECTIVE")]
    log: Option<String>,
}

/// The format used for log output on stderr.
#[derive(Debug, Default, Clone, Copy, clap::ValueEnum)]
pub(crate) enum LogFormat {
    /// Automatically detect: use "text" for terminals, "json" otherwise.
    #[default]
    Auto,

    /// Human-readable compact text format with ANSI colors.
    Text,

    /// Machine-readable JSON format, one object per line.
    Json,
}

#[derive(Debug, Clone)]
pub(crate) enum KeyValueOrPath {
    KeyValue(KvAssignment),
    Path(Utf8PathBuf),
}

impl FromStr for KeyValueOrPath {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        // String prefixed with `@` is always a path.
        if let Some(s) = s.strip_prefix(PATH_STRING_PREFIX) {
            return Ok(Self::Path(Utf8PathBuf::from(s.trim())));
        }

        // A JSON object is treated as a root-level config assignment that
        // merges each top-level key individually.
        if s.starts_with('{') {
            let value: serde_json::Value =
                serde_json::from_str(s).map_err(|e| Error::CliConfig(e.to_string()))?;
            if !value.is_object() {
                return Err(Error::CliConfig(
                    "--cfg JSON value must be an object".into(),
                ));
            }
            return Ok(Self::KeyValue(KvAssignment::root_json(value)));
        }

        // String without `=` is always a path.
        if !s.contains('=') {
            return Ok(Self::Path(Utf8PathBuf::from(s.trim())));
        }

        // Anything else is parsed as a key-value pair.
        s.parse().map(Self::KeyValue).map_err(Into::into)
    }
}

#[derive(Debug, Clone)]
pub(crate) enum WorkspaceIdOrPath {
    Id(jp_workspace::Id),
    Path(Utf8PathBuf),
}

impl FromStr for WorkspaceIdOrPath {
    type Err = Error;

    fn from_str(s: &str) -> Result<Self> {
        if Utf8PathBuf::from(s).exists() {
            return Ok(Self::Path(Utf8PathBuf::from(s)));
        }

        Ok(Self::Id(jp_workspace::Id::from_str(s)?))
    }
}

/// The format of the CLI output written to stdout.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum CliFormat {
    /// Automatically detect: use "text-pretty" for terminals, "text" otherwise.
    #[default]
    Auto,

    /// Plain text output.
    /// No ANSI colors or unicode decorations.
    Text,

    /// Pretty-printed text output.
    /// Includes ANSI colors, unicode decorations, and hyperlinks.
    TextPretty,

    /// Compact JSON output.
    Json,

    /// Pretty-printed multi-line JSON output.
    JsonPretty,
}

impl CliFormat {
    /// Resolve `Auto` based on TTY detection, returning the concrete
    /// [`OutputFormat`].
    #[must_use]
    pub(crate) fn resolve(self, is_tty: bool) -> OutputFormat {
        match self {
            Self::Auto if is_tty => OutputFormat::TextPretty,
            Self::Auto | Self::Text => OutputFormat::Text,
            Self::TextPretty => OutputFormat::TextPretty,
            Self::Json => OutputFormat::Json,
            Self::JsonPretty => OutputFormat::JsonPretty,
        }
    }
}

impl fmt::Display for Cli {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_map()
            .entry(&"config", &self.globals.config)
            .entry(&"verbose", &self.globals.verbose)
            .entry(&"quiet", &self.globals.quiet)
            .finish()
    }
}

#[expect(clippy::print_stdout, clippy::print_stderr)]
pub fn run() -> ExitCode {
    #[cfg(feature = "dhat")]
    let _profiler = run_dhat();

    let cli = match Cli::try_parse() {
        Ok(cli) => cli,
        Err(e) => {
            if e.kind() == clap::error::ErrorKind::DisplayHelp && is_root_help_request() {
                drop(e.print());
                print_plugin_help_section();
                return ExitCode::from(0);
            }
            // All other cases (subcommand help, version, errors): let clap handle it.
            e.exit();
        }
    };
    let is_tty = stdout().is_terminal();

    let format = cli.globals.format.resolve(is_tty);

    let guard = configure_logging(
        cli.globals.verbose,
        cli.globals.quiet,
        cli.globals.log_format,
        format,
        cli.globals.log_file.as_deref(),
        cli.globals.log.as_deref(),
    );

    trace!(command = cli.command.name(), arguments = %cli, "Starting CLI run.");
    let (code, output) = match run_inner(cli, format) {
        Ok(()) => (0, None),
        Err(error) => {
            let (code, msg) = parse_error(error.into(), format);
            (code, Some(msg))
        }
    };

    if let Some(output) = output
        && !output.trim().is_empty()
    {
        if code == 0 {
            println!("{output}");
        } else {
            eprintln!("{output}");
        }
    }

    if (code != 0
        || env::var("JP_DEBUG")
            .as_deref()
            .is_ok_and(|v| v == "1" || v == "true"))
        && let Some(path) = guard.and_then(TracingGuard::persist)
    {
        if format.is_json() {
            let msg = serde_json::json!({ "trace_log": path.as_str() });
            eprintln!("{msg}");
        } else {
            eprintln!("\nFull trace log written to: {path}");
        }
    }

    #[cfg(feature = "dhat")]
    eprintln!("You can view the heap profile at https://profiler.firefox.com");

    ExitCode::from(code)
}

fn run_inner(cli: Cli, format: OutputFormat) -> Result<()> {
    let printer = Printer::terminal(format);

    // `jp init` is a special case that doesn't need the full startup pipeline.
    if let Commands::Init(args) = &cli.command {
        return args.run(&printer).map_err(Into::into);
    }

    let (mut workspace, fs_backend) =
        load_workspace(cli.globals.workspace.as_ref(), cli.globals.persist)?;

    trace!("Sanitizing workspace.");
    let report = workspace.sanitize()?;
    if report.has_repairs() {
        for trashed in &report.trashed {
            warn!(
                dirname = trashed.dirname,
                error = %trashed.error,
                "Trashed corrupt conversation"
            );
        }
    }

    trace!("Resolving session identity.");
    let session = session::resolve();

    // Populate the conversation index. This does NOT load the contents of
    // individual conversations, this is done lazily as needed.
    workspace.load_conversation_index();

    // Config Loading Phase 1: Load static sources (files + env + --cfg) once.
    let base = load_base_partial(fs_backend.as_deref())?;
    let pipeline = ConfigPipeline::new(
        base,
        &cli.globals.config,
        Some(&workspace),
        fs_backend.as_deref(),
    )?;

    // Extract default_id for conversation resolution. This builds a temporary
    // partial (base + --cfg + command-CLI) without the per-conversation layer.
    let default_id = {
        let mut cfg = pipeline.partial_without_conversation()?;
        cfg = cli
            .command
            .apply_cli_config(Some(&workspace), cfg, None)
            .map_err(|error| Error::CliConfig(error.to_string()))?;

        cfg.conversation.default_id.take().unwrap_or_default()
    };

    let request = cli.command.conversation_load_request();
    let handles = resolve_request(&request, &workspace, session.as_ref(), default_id)?;

    // Config Loading Phase 2: Build final config with per-conversation layer.
    let config_handle = request.config_conversation.and_then(|idx| handles.get(idx));
    if let Some(handle) = config_handle
        && let Err(error) = workspace.eager_load_conversation(handle)
    {
        tracing::warn!(error = ?error, "Failed to eager-load conversation.");
    }

    let conversation_partial = config_handle
        .map(|handle| {
            cli.command
                .apply_conversation_config(&workspace, PartialAppConfig::default(), None, handle)
                .map_err(|error| Error::CliConfig(error.to_string()))
        })
        .transpose()?;

    let mut partial = match conversation_partial {
        Some(conversation_config) => pipeline.partial_with_conversation(conversation_config)?,
        None => pipeline.partial_without_conversation()?,
    };

    partial = cli
        .command
        .apply_cli_config(Some(&workspace), partial, None)
        .map_err(|error| Error::CliConfig(error.to_string()))?;

    let config = Arc::new(build(partial)?);
    let runtime = build_runtime(cli.root.threads, "jp-worker")?;
    let mut ctx = Ctx::new(
        workspace,
        fs_backend,
        runtime,
        cli.globals,
        config,
        session,
        printer,
    );
    let rt = ctx.handle().clone();

    // Run the requested command.
    let output = rt.block_on(cli.command.run(&mut ctx, handles));

    if let Err(error) = output.as_ref()
        && error.disable_persistence
    {
        tracing::info!(
            error = error.to_string(),
            "Error running command. Disabling workspace persistence."
        );
        ctx.workspace.disable_persistence();
    }

    // Flush the printer to ensure all queued typewriter output is fully written
    // before background tasks log any errors.
    ctx.printer.flush();

    // Drain background tasks. Shows a timer line while waiting and lets the
    // user interrupt: first Ctrl+C signals graceful cancellation with a 2s
    // countdown; a second Ctrl+C — or SIGQUIT — escalates to a force quit
    // that aborts tasks immediately and drops their pending workspace
    // mutations.
    rt.block_on(drain_background_tasks(&mut ctx))
        .map_err(Error::Task)?;

    // Remove ephemeral conversations that are no longer needed, but protect
    // any conversation that is active in a terminal session.
    let active_ids = ctx.workspace.all_active_conversation_ids();
    ctx.workspace.remove_ephemeral_conversations(&active_ids);

    // Remove orphaned lock files and stale session mappings.
    ctx.workspace.cleanup_stale_files(ctx.fs_backend.as_deref());

    output.map_err(Into::into)
}

/// Drain background tasks at end of run, with interactive cancellation.
///
/// While [`TaskHandler::sync`] runs, prints a `⏱ Finishing background tasks…
/// Ns` line on stderr after a 1s delay.
/// The first SIGINT/SIGTERM switches the line to a 2s countdown and signals
/// graceful cancellation; a second SIGINT (or any SIGQUIT) escalates to a force
/// quit that aborts the `JoinSet` and drops pending workspace mutations.
///
/// [`TaskHandler::sync`]: jp_task::TaskHandler::sync
async fn drain_background_tasks(
    ctx: &mut Ctx,
) -> std::result::Result<(), Box<dyn std::error::Error + Send + Sync>> {
    if ctx.task_handler.is_empty() {
        return Ok(());
    }

    let cancel = ctx.task_handler.cancel_token();
    let force = ctx.task_handler.force_token();
    let printer = ctx.printer.clone();
    let mut signals = ctx.signals.receiver.resubscribe();
    let show_chrome = ctx.term.is_tty;
    let mut signals_open = true;

    let mut timer = if show_chrome {
        spawn_line_timer(
            printer.clone(),
            true,
            Duration::from_secs(1),
            Duration::from_millis(100),
            |secs| format!("\r\x1b[K⏱ Finishing background tasks… {secs:.1}s"),
        )
    } else {
        None
    };

    let sync_fut = ctx
        .task_handler
        .sync(&mut ctx.workspace, Duration::from_secs(10));
    tokio::pin!(sync_fut);

    let result = loop {
        tokio::select! {
            biased;
            sig = signals.recv(), if signals_open => {
                let escalate = match sig {
                    Ok(SignalTo::Shutdown) => cancel.is_cancelled(),
                    Ok(SignalTo::Quit) => true,
                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(broadcast::error::RecvError::Closed) => {
                        signals_open = false;
                        continue;
                    }
                };

                stop_drain_timer(timer.take()).await;

                if escalate {
                    force.cancel();
                    if show_chrome {
                        drop(writeln!(
                            printer.err_writer(),
                            "\r\x1b[K⏱ Aborting background tasks…",
                        ));
                    }
                    // No further escalation possible; stop watching signals.
                    signals_open = false;
                } else {
                    cancel.cancel();
                    if show_chrome {
                        timer = spawn_line_timer(
                            printer.clone(),
                            true,
                            Duration::ZERO,
                            Duration::from_millis(100),
                            |secs| format!(
                                "\r\x1b[K⏱ Cancelling background tasks… {:.1}s",
                                (2.0 - secs).max(0.0),
                            ),
                        );
                    }
                }
            }
            result = &mut sync_fut => break result,
        }
    };

    stop_drain_timer(timer).await;
    result
}

async fn stop_drain_timer(timer: Option<(CancellationToken, JoinHandle<()>)>) {
    if let Some((token, handle)) = timer {
        token.cancel();
        drop(handle.await);
    }
}

/// Check if the current invocation is a root-level help request (`jp -h`).
///
/// We only inject the "Plugins:" section for root help, not for subcommand help
/// like `jp query -h`.
fn is_root_help_request() -> bool {
    let args: Vec<String> = env::args().collect();
    args.len() == 2 && (args[1] == "-h" || args[1] == "--help")
}

/// Discover plugins on `$PATH`, describe them, and print a "Plugins:" section.
fn print_plugin_help_section() {
    let plugins = discover_plugins();
    if plugins.is_empty() {
        return;
    }

    let mut descriptions: Vec<(String, String)> = Vec::new();
    for (name, binary) in &plugins {
        let desc = describe_plugin(binary);
        let display_name = desc
            .as_ref()
            .filter(|d| !d.command.is_empty())
            .map_or_else(|| name.clone(), |d| d.command.join(" "));
        let description = desc.map_or_else(|| "(no description)".into(), |d| d.description);
        descriptions.push((display_name, description));
    }

    let mut out = io::stdout().lock();
    drop(writeln!(out, "\nPlugins:"));
    for (name, desc) in &descriptions {
        drop(writeln!(out, "  {name:<16}{desc}"));
    }
}

fn parse_error(error: cmd::Error, format: OutputFormat) -> (u8, String) {
    let cmd::Error {
        code,
        message,
        mut metadata,
        ..
    } = error;

    if !format.is_json() {
        let rows: Vec<Row> = metadata
            .into_iter()
            .map(|(k, v)| {
                let mut row = Row::new();
                row.add_cell(Cell::new(k).set_alignment(CellAlignment::Right))
                    .add_cell(
                        Cell::new(match v {
                            Value::String(s) => s,
                            v => format!("{v:#}"),
                        })
                        .set_alignment(CellAlignment::Left),
                    );
                row
            })
            .collect();

        let rendered = if format.is_pretty() {
            details(message.as_deref(), rows)
        } else {
            details_markdown(message.as_deref(), rows)
        };

        return (code.into(), rendered);
    }

    let error = serde_json::json!({
        "message": message,
        "metadata": metadata,
        "code": code,
    });

    let error = if format.is_json_pretty() {
        serde_json::to_string_pretty(&error)
    } else {
        serde_json::to_string(&error)
    }
    .unwrap_or_else(|err| {
        metadata.push(("source".to_owned(), Value::String(error.to_string())));

        let error = serde_json::json!({
            "message": err.to_string(),
            "metadata": metadata,
            "code": 127,
        });

        format!("{error}")
    });

    (code.into(), error)
}

/// Load the base partial config from files and environment variables.
///
/// This produces the `files + inheritance + env` layer that serves as input to
/// [`ConfigPipeline`].
/// No `--cfg` args or per-conversation config.
///
/// See: <https://jp.computer/configuration>
fn load_base_partial(fs: Option<&FsStorageBackend>) -> Result<PartialAppConfig> {
    let partials = load_partial_configs_from_files(fs, absolute_utf8(".").ok())?;
    let partial = load_partials_with_inheritance(partials)?;

    load_envs(partial).map_err(|error| Error::CliConfig(error.to_string()))
}

fn load_partial_configs_from_files(
    fs: Option<&FsStorageBackend>,
    cwd: Option<Utf8PathBuf>,
) -> Result<Vec<PartialAppConfig>> {
    let config_path = RelativePath::new("config.toml");
    let mut partials = vec![];

    // Load the user-global config file (see RFD D20).
    let home = env::home_dir().and_then(|p| Utf8PathBuf::from_path_buf(p).ok());
    if let Some(user_global_config) = user_global_config_dir(home.as_deref())
        .and_then(|p| load_partial_at_path(p.join("config.toml")).transpose())
        .transpose()?
    {
        partials.push(user_global_config);
    }

    // Load `$WORKSPACE_ROOT/.jp/config.{toml,json,yaml}`.
    if let Some(workspace_config) = fs
        .map(|f| f.root_with_path(config_path))
        .and_then(|p| load_partial_at_path(p).transpose())
        .transpose()?
    {
        partials.push(workspace_config);
    }

    // Load `$CWD/.jp.{toml,json,yaml}`, recursing up the directory tree until
    // either the root of the workspace, or filesystem is reached.
    if let Some(cwd_config) = cwd
        .and_then(|cwd| {
            load_partial_at_path_recursive(
                cwd.join(".jp.toml"),
                Workspace::find_root(cwd, DEFAULT_STORAGE_DIR).as_deref(),
            )
            .transpose()
        })
        .transpose()?
    {
        partials.push(cwd_config);
    }

    // Load `$XDG_DATA_HOME/jp/<workspace_id>/config.{toml,json,yaml}`.
    if let Some(user_workspace_config) = fs
        .and_then(|f| f.user_storage_with_path(config_path))
        .and_then(|p| load_partial_at_path(p).transpose())
        .transpose()?
    {
        partials.push(user_workspace_config);
    }

    Ok(partials)
}

/// Find the workspace for the current directory.
///
/// When `persist` is `false` (`--no-persist`), the persist backend is swapped
/// to [`NullPersistBackend`] and the lock backend to [`NullLockBackend`] so
/// that ephemeral queries never write to disk and never block on lock
/// contention.
fn load_workspace(
    workspace: Option<&WorkspaceIdOrPath>,
    persist: bool,
) -> Result<(Workspace, Option<Arc<FsStorageBackend>>)> {
    let cwd = match workspace {
        None => absolute_utf8(".")?,
        Some(WorkspaceIdOrPath::Path(path)) => path.clone(),

        // TODO: Centralize this in a new `UserStorage` struct.
        Some(WorkspaceIdOrPath::Id(id)) => user_data_dir()?
            .read_dir()?
            .map(|dir| dir.ok().map(|dir| dir.path().clone()))
            .find_map(|path| {
                path.filter(|dir| {
                    dir.file_name()
                        .and_then(|v| v.to_str())
                        .is_some_and(|v| v.ends_with(&id.to_string()))
                })
            })
            .ok_or(jp_workspace::Error::MissingStorage)?
            .join("storage")
            .canonicalize()?
            .try_into()
            .map_err(FromPathBufError::into_io_error)?,
    };
    trace!(cwd = %cwd, "Finding workspace.");

    let root = Workspace::find_root(cwd, DEFAULT_STORAGE_DIR).ok_or(cmd::Error::from(format!(
        "Could not locate workspace. Use `{}` to create a new workspace.",
        "jp init".bold().yellow()
    )))?;
    trace!(root = %root, "Found workspace root.");

    let storage = root.join(DEFAULT_STORAGE_DIR);
    trace!(storage = %storage, "Initializing workspace storage.");

    let id = jp_workspace::Id::load(&storage)
        .transpose()
        .ok()
        .flatten()
        .unwrap_or_default();

    trace!(%id, "Loaded unique workspace ID.");

    let fs = FsStorageBackend::new(&storage).map_err(jp_workspace::Error::from)?;

    let user_root = user_data_dir()?.join("workspace");
    let name = root
        .file_name()
        .ok_or_else(|| jp_workspace::Error::NotDir(root.clone()))?;
    let fs = fs
        .with_user_storage(&user_root, name, id.to_string())
        .map_err(jp_workspace::Error::from)?;

    let fs = Arc::new(fs);
    let mut workspace = Workspace::new_with_id(root, id).with_backend(fs.clone());
    if !persist {
        workspace = workspace
            .with_persist(Arc::new(NullPersistBackend))
            .with_locker(Arc::new(NullLockBackend));
    }
    info!(workspace = %workspace.root(), "Using existing workspace.");

    workspace.id().store(&storage)?;

    Ok((workspace, Some(fs)))
}

const JP_CRATES: &[&str] = &[
    "attachment",
    "attachment_bear_note",
    "attachment_cmd_output",
    "attachment_file_content",
    "attachment_http_content",
    "attachment_internal",
    "attachment_mcp_resources",
    "cli",
    "config",
    "conversation",
    "format",
    "id",
    "inquire",
    "llm",
    "macro",
    "mcp",
    "md",
    "openrouter",
    "printer",
    "serde",
    "storage",
    "task",
    "term",
    "test",
    "tombmap",
    "tool",
    "workspace",
];

pub struct TracingGuard {
    file: Option<NamedUtf8TempFile>,
}

impl TracingGuard {
    fn persist(mut self) -> Option<Utf8PathBuf> {
        self.file
            .take()
            .and_then(|file| file.keep().ok().map(|(_file, path)| path))
    }
}

fn configure_logging(
    verbose: u8,
    quiet: bool,
    log_format: LogFormat,
    output_format: OutputFormat,
    log_file: Option<&str>,
    log_filter: Option<&str>,
) -> Option<TracingGuard> {
    use tracing::level_filters::LevelFilter;
    use tracing_subscriber::{fmt, prelude::*};

    let (mut level, more) = match verbose {
        0 => (LevelFilter::ERROR, 0),
        1 => (LevelFilter::WARN, 0),
        2 => (LevelFilter::INFO, 0),
        3 => (LevelFilter::DEBUG, 0),
        4 => (LevelFilter::TRACE, 0),
        5 => (LevelFilter::TRACE, 1),
        _ => (LevelFilter::TRACE, 2),
    };

    if quiet {
        level = LevelFilter::OFF;
    }

    let reasonable_more = [
        "trace",
        "h2=off",
        "hyper_util=off",
        "ignore=off",
        "mio=off",
        "reqwest=off",
        "rustls=off",
        "tokio=off",
    ];

    // File layer: always captures full trace for post-mortem debugging.
    let mut file_filter = vec![reasonable_more.to_vec().join(",")];
    for krate in JP_CRATES {
        file_filter.push(format!("jp_{krate}=trace"));
    }
    // Plugin stderr and protocol log messages.
    file_filter.push("plugin=trace".to_owned());
    let file_env_filter = tracing_subscriber::EnvFilter::new(file_filter.join(","));

    let file = NamedUtf8TempFile::new().ok()?;
    let file_writer = file.as_file().try_clone().ok()?;

    let file_layer = fmt::layer()
        .json()
        .with_ansi(false)
        .with_writer(Mutex::new(file_writer))
        .with_filter(file_env_filter);

    let registry = tracing_subscriber::registry().with(file_layer);

    // Stderr layer: only enabled when the user asks for it.
    //
    // By default, tracing goes only to the log file. Stderr is reserved for
    // chrome (progress indicators, tool headers). This keeps `2> chrome.log`
    // clean of tracing noise.
    //
    // `-v` implies stderr output (the user wants to see logs).
    // `--log-file=-` is an explicit opt-in.
    // `--log` is an explicit opt-in (otherwise the flag would silently do
    // nothing without also passing `-v`).
    // `--quiet` suppresses stderr output regardless.
    let log_to_stderr =
        !quiet && (verbose > 0 || log_filter.is_some() || log_file.is_some_and(|f| f == "-"));

    if log_to_stderr {
        let mut term_filter: Vec<_> = match more {
            0 => vec!["off".to_owned()],
            1 => vec![reasonable_more.to_vec().join(",")],
            _ => vec!["trace".to_owned()],
        };
        for krate in JP_CRATES {
            term_filter.push(format!("jp_{krate}={level}"));
        }
        // Plugin stderr and protocol log messages.
        term_filter.push(format!("plugin={level}"));
        // User-supplied directives come last so they override the baseline
        // for any targets they mention.
        if let Some(directive) = log_filter {
            term_filter.push(directive.to_owned());
        }
        let term_env_filter = tracing_subscriber::EnvFilter::new(term_filter.join(","));

        let use_json = match log_format {
            LogFormat::Json => true,
            LogFormat::Text => false,
            // When stdout is JSON, force stderr logging to JSON too so
            // consumers can parse both streams reliably.
            LogFormat::Auto => output_format.is_json() || !stderr().is_terminal(),
        };

        if use_json {
            let layer = fmt::layer().json().with_ansi(false).with_writer(io::stderr);
            let layer = if level < LevelFilter::DEBUG {
                layer.without_time().boxed()
            } else {
                layer.boxed()
            };
            registry.with(layer.with_filter(term_env_filter)).init();
        } else {
            let format = fmt::format().with_target(more > 0).compact();
            let layer = fmt::layer()
                .event_format(format)
                .with_ansi(true)
                .with_writer(io::stderr);

            if level < LevelFilter::DEBUG {
                registry
                    .with(layer.without_time().with_filter(term_env_filter))
                    .init();
            } else {
                registry.with(layer.with_filter(term_env_filter)).init();
            }
        }
    } else {
        // No stderr layer. Logs go only to the file.
        registry.init();
    }

    Some(TracingGuard { file: Some(file) })
}

/// Get the number of worker threads to use.
pub fn worker_threads() -> Option<NonZeroUsize> {
    NonZeroUsize::new(WORKER_THREADS.load(Ordering::Relaxed))
}

/// Build an async runtime.
///
/// # Panics
///
/// Panics if called twice.
pub(crate) fn build_runtime(threads: Option<NonZeroUsize>, thread_name: &str) -> Result<Runtime> {
    let mut rt_builder = runtime::Builder::new_multi_thread();
    rt_builder.max_blocking_threads(1024);
    rt_builder.enable_all().thread_name(thread_name);

    let worker_threads = threads.unwrap_or_else(num_threads).get();
    WORKER_THREADS
        .compare_exchange(0, worker_threads, Ordering::Acquire, Ordering::Relaxed)
        .expect("double thread initialization");
    rt_builder.worker_threads(worker_threads);

    debug!(worker_threads, "Building runtime.");
    rt_builder.build().map_err(Into::into)
}

/// Returns an estimate of the number of recommended threads that JP should
/// spawn.
pub fn num_threads() -> NonZeroUsize {
    match thread::available_parallelism() {
        Ok(count) => count,
        Err(error) => {
            warn!(%error, "Failed to determine available parallelism for thread count, defaulting to 1.");
            num::NonZeroUsize::MIN
        }
    }
}

#[cfg(feature = "dhat")]
fn run_dhat() -> dhat::Profiler {
    use std::path::PathBuf;

    std::process::Command::new(env!("CARGO"))
        .arg("locate-project")
        .arg("--workspace")
        .arg("--message-format=plain")
        .output()
        .ok()
        .and_then(|v| String::from_utf8(v.stdout).ok())
        .and_then(|v| PathBuf::from(v).parent().map(|v| v.join("tmp/profiling")))
        .and_then(|v| std::fs::create_dir_all(&v).ok().map(|()| v))
        .map(|v| v.join(format!("heap-{}.json", chrono::Utc::now().timestamp())))
        .map_or_else(dhat::Profiler::new_heap, |v| {
            dhat::Profiler::builder().file_name(v).build()
        })
}

#[cfg(test)]
#[path = "lib_tests.rs"]
mod tests;
