pub mod args;
pub mod config;
pub mod error;
pub mod introspect;
pub mod logging;
pub mod mapping;
pub mod protocol;
pub mod transfer;
pub mod workflow;

use args::Cli;
use clap::Parser;
use error::{ErrorContext, RosWireResult};
use mapping::{ActionKind, ProtocolRequest};
use protocol::classic::{
    transport::{ApiStream, TcpApiStream, TlsApiStream},
    ClassicApiSession,
};
use protocol::rest::RestClient;
use protocol::{ProbeResult, ProtocolProbe, RequestedProtocol, RouterOsMajor, SelectedProtocol};
use serde::Serialize;
use serde_json::Value;
use std::collections::BTreeMap;
use std::time::Duration;

pub fn run() -> RosWireResult<()> {
    let cli = Cli::parse();
    let env = read_env_map();
    let mut logger = logging::RuntimeLogger::initialize(&cli, &env);
    if let Some(payload) = logger.debug_payload() {
        eprintln!("{payload}");
    }
    logger.log_start();

    let result = run_with_cli(&cli);
    match &result {
        Ok(()) => logger.log_success(),
        Err(error) => logger.log_error(error),
    }

    result
}

fn run_with_cli(cli: &Cli) -> RosWireResult<()> {
    if cli.simulate_error {
        return Err(Box::new(
            error::RosWireError::usage("simulated usage error for contract tests")
                .with_hint("remove --simulate-error to continue"),
        ));
    }

    if let Some(result) = config::handle(&cli.tokens, cli) {
        let payload = result?;
        println!("{payload}");
        return Ok(());
    }

    if let Some(result) = introspect::handle(&cli.tokens, cli) {
        let payload = result?;
        println!("{payload}");
        return Ok(());
    }

    if let Some(result) = workflow::handle(&cli.tokens, cli) {
        match result? {
            workflow::WorkflowResult::Payload(payload) => {
                println!("{payload}");
                return Ok(());
            }
            workflow::WorkflowResult::Invocation(invocation) => {
                return execute_invocation(invocation, cli);
            }
        }
    }

    if let Some(result) = transfer::handle(&cli.tokens, cli) {
        let payload = result?;
        println!("{payload}");
        return Ok(());
    }

    let invocation = args::parse_invocation(&cli.tokens)?;
    execute_invocation(invocation, cli)
}

fn execute_invocation(invocation: args::ParsedInvocation, cli: &Cli) -> RosWireResult<()> {
    let request = mapping::build_protocol_request(&invocation)?;
    if cli.dry_run {
        let payload = render_command_plan(&invocation, &request, cli)?;
        println!("{payload}");
        return Ok(());
    }

    validate_raw_safety(&invocation, &request, cli)?;

    let target = resolve_execution_target(cli)?;
    if target.requested_protocol == "auto" {
        return execute_auto(&invocation, &request, &target);
    }

    if target.requested_protocol == "rest" {
        return execute_rest(&invocation, &request, &target, target.port, "rest");
    }

    if target.requested_protocol == "api-ssl" {
        return execute_api_ssl(&invocation, &request, &target, target.port);
    }

    execute_api(&invocation, &request, &target, target.port)
}

fn validate_raw_safety(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    cli: &Cli,
) -> RosWireResult<()> {
    if !request.mapping.is_raw() || request.mapping.action_kind == ActionKind::Print {
        return Ok(());
    }

    if cli.allow_write {
        return Ok(());
    }

    Err(Box::new(
        error::RosWireError::usage(
            "raw RouterOS commands that may mutate state require --allow-write",
        )
        .with_hint("use raw /.../print for read-only commands, or add --allow-write for explicit raw writes")
        .with_context(ErrorContext {
            command: mapping::command_name(invocation),
            path: invocation.path.clone(),
            action: invocation.action.clone(),
            resolved_args: error::redact_resolved_args(&invocation.resolved_args),
            ..ErrorContext::default()
        }),
    ))
}

fn execute_auto(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    target: &ExecutionTarget,
) -> RosWireResult<()> {
    let probe = LiveProtocolProbe { request, target };
    let decision = with_context(
        protocol::route_protocol(
            RequestedProtocol::Auto,
            request.mapping.has_rest_mapping(),
            None,
            &probe,
        ),
        execution_context(invocation, target, "unknown"),
    )?;

    match decision.selected_protocol {
        SelectedProtocol::Rest => {
            execute_rest(invocation, request, target, default_port("rest"), "rest")
        }
        SelectedProtocol::ApiSsl => {
            execute_api_ssl(invocation, request, target, default_port("api-ssl"))
        }
        SelectedProtocol::Api => execute_api(invocation, request, target, default_port("api")),
    }
}

fn execute_rest(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    target: &ExecutionTarget,
    port: u16,
    selected_protocol: &str,
) -> RosWireResult<()> {
    let context = execution_context(invocation, target, selected_protocol);
    let client = RestClient::https(&target.host, port, &target.user, &target.password);
    let value = with_context(client.execute_request(request), context)?;
    let payload = render_protocol_payload(request, selected_protocol, &value)?;
    println!("{payload}");

    Ok(())
}

fn execute_api_ssl(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    target: &ExecutionTarget,
    port: u16,
) -> RosWireResult<()> {
    let context = execution_context(invocation, target, "api-ssl");
    let stream = with_context(
        TlsApiStream::connect(&target.host, port, Duration::from_secs(10)),
        context.clone(),
    )?;
    execute_classic_stream(stream, request, &target.user, &target.password, context)
}

fn execute_api(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    target: &ExecutionTarget,
    port: u16,
) -> RosWireResult<()> {
    let context = execution_context(invocation, target, "api");
    let stream = with_context(
        TcpApiStream::connect(&target.host, port, Duration::from_secs(10)),
        context.clone(),
    )?;
    execute_classic_stream(stream, request, &target.user, &target.password, context)
}

fn execute_classic_stream<S: ApiStream>(
    stream: S,
    request: &ProtocolRequest,
    user: &str,
    password: &str,
    context: ErrorContext,
) -> RosWireResult<()> {
    let mut session = ClassicApiSession::new(stream);
    let selected_protocol = context.selected_protocol.clone();
    with_context(session.login(user, password), context.clone())?;
    let rows = with_context(session.execute_request(request), context)?;
    let payload = render_protocol_payload(request, &selected_protocol, &rows)?;
    println!("{payload}");

    Ok(())
}

#[derive(Debug, Serialize)]
struct WriteSuccessPayload<'a> {
    schema_version: &'static str,
    status: &'static str,
    command: String,
    action: &'static str,
    selected_protocol: &'a str,
    response: Value,
}

#[derive(Debug, Serialize)]
struct CommandPlanPayload {
    schema_version: &'static str,
    dry_run: bool,
    command: String,
    routeros_path: String,
    action: String,
    requested_protocol: String,
    selected_protocol: &'static str,
    routeros_version: &'static str,
    resolved_args: BTreeMap<String, String>,
    flags: Vec<String>,
    side_effects: Vec<String>,
    idempotency: String,
    will_connect: bool,
    will_modify_routeros: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    rest_mapping: Option<RestPlanMapping>,
}

#[derive(Debug, Serialize)]
struct RestPlanMapping {
    method: &'static str,
    path: String,
}

fn render_command_plan(
    invocation: &args::ParsedInvocation,
    request: &ProtocolRequest,
    cli: &Cli,
) -> RosWireResult<String> {
    let rest_mapping = request
        .mapping
        .rest_mapping
        .as_ref()
        .map(|mapping| RestPlanMapping {
            method: mapping.method.as_str(),
            path: mapping.path.clone(),
        });

    serialize_payload(
        &CommandPlanPayload {
            schema_version: "roswire.command.plan.v1",
            dry_run: true,
            command: mapping::command_name(invocation),
            routeros_path: request.mapping.routeros_path.clone(),
            action: request.mapping.action_kind.as_str().to_owned(),
            requested_protocol: cli
                .protocol
                .map(|protocol| protocol.as_str())
                .unwrap_or("auto")
                .to_owned(),
            selected_protocol: "not-selected",
            routeros_version: "not-probed",
            resolved_args: error::redact_resolved_args(&request.resolved_args),
            flags: request.flags.clone(),
            side_effects: request.mapping.side_effects.clone(),
            idempotency: request.mapping.idempotency.clone(),
            will_connect: false,
            will_modify_routeros: request.mapping.action_kind != ActionKind::Print,
            rest_mapping,
        },
        "RouterOS command dry-run plan",
    )
}

fn render_protocol_payload<T: Serialize>(
    request: &ProtocolRequest,
    selected_protocol: &str,
    response: &T,
) -> RosWireResult<String> {
    if request.mapping.action_kind == ActionKind::Print {
        return serialize_payload(response, "RouterOS response");
    }

    serialize_payload(
        &WriteSuccessPayload {
            schema_version: "roswire.write.v1",
            status: "ok",
            command: request_command_name(request),
            action: request.mapping.action_kind.as_str(),
            selected_protocol,
            response: sanitized_response_value(response)?,
        },
        "RouterOS write response",
    )
}

fn sanitized_response_value<T: Serialize>(response: &T) -> RosWireResult<Value> {
    let mut value = serde_json::to_value(response).map_err(|error| {
        Box::new(error::RosWireError::internal(format!(
            "failed to serialize RouterOS write response: {error}",
        )))
    })?;
    redact_sensitive_json_fields(&mut value);
    Ok(value)
}

fn redact_sensitive_json_fields(value: &mut Value) {
    match value {
        Value::Object(map) => {
            for (key, value) in map {
                if error::is_sensitive_key(key) {
                    *value = Value::String(error::redact_value(value.as_str().unwrap_or("value")));
                } else {
                    redact_sensitive_json_fields(value);
                }
            }
        }
        Value::Array(items) => {
            for item in items {
                redact_sensitive_json_fields(item);
            }
        }
        _ => {}
    }
}

fn serialize_payload<T: Serialize>(value: &T, label: &str) -> RosWireResult<String> {
    serde_json::to_string_pretty(value).map_err(|error| {
        Box::new(error::RosWireError::internal(format!(
            "failed to serialize {label}: {error}",
        )))
    })
}

fn request_command_name(request: &ProtocolRequest) -> String {
    if request.mapping.is_raw() {
        return request
            .mapping
            .routeros_path
            .trim_start_matches('/')
            .to_owned();
    }

    request
        .mapping
        .cli_path
        .iter()
        .map(String::as_str)
        .chain(std::iter::once(request.mapping.action_kind.as_str()))
        .collect::<Vec<_>>()
        .join("/")
}

struct LiveProtocolProbe<'a> {
    request: &'a ProtocolRequest,
    target: &'a ExecutionTarget,
}

impl ProtocolProbe for LiveProtocolProbe<'_> {
    fn probe(&self, protocol: SelectedProtocol) -> ProbeResult {
        match protocol {
            SelectedProtocol::Rest => self.probe_rest(),
            SelectedProtocol::ApiSsl => self.probe_api_ssl(),
            SelectedProtocol::Api => self.probe_api(),
        }
    }
}

impl LiveProtocolProbe<'_> {
    fn probe_rest(&self) -> ProbeResult {
        let client = RestClient::https(
            &self.target.host,
            default_port("rest"),
            &self.target.user,
            &self.target.password,
        );
        match client.system_resource() {
            Ok(value) => ProbeResult::Success {
                routeros_major: routeros_major_from_rest_resource(&value),
                rest_supported_for_action: self.request.mapping.has_rest_mapping(),
            },
            Err(error) => classify_probe_error(&error),
        }
    }

    fn probe_api_ssl(&self) -> ProbeResult {
        match TlsApiStream::connect(
            &self.target.host,
            default_port("api-ssl"),
            Duration::from_secs(10),
        ) {
            Ok(stream) => self.probe_classic(stream),
            Err(error) => classify_probe_error(&error),
        }
    }

    fn probe_api(&self) -> ProbeResult {
        match TcpApiStream::connect(
            &self.target.host,
            default_port("api"),
            Duration::from_secs(10),
        ) {
            Ok(stream) => self.probe_classic(stream),
            Err(error) => classify_probe_error(&error),
        }
    }

    fn probe_classic<S: ApiStream>(&self, stream: S) -> ProbeResult {
        let mut session = ClassicApiSession::new(stream);
        if let Err(error) = session.login(&self.target.user, &self.target.password) {
            return classify_probe_error(&error);
        }
        match session.probe_resource() {
            Ok(resource) => ProbeResult::Success {
                routeros_major: resource.routeros_major(),
                rest_supported_for_action: false,
            },
            Err(error) => classify_probe_error(&error),
        }
    }
}

fn classify_probe_error(error: &error::RosWireError) -> ProbeResult {
    match error.error_code {
        error::ErrorCode::AuthFailed => ProbeResult::AuthFailed,
        _ => ProbeResult::NetworkFailure,
    }
}

fn routeros_major_from_rest_resource(value: &Value) -> RouterOsMajor {
    value
        .get("version")
        .and_then(Value::as_str)
        .map(routeros_major_from_version)
        .unwrap_or(RouterOsMajor::Unknown)
}

fn routeros_major_from_version(version: &str) -> RouterOsMajor {
    if version.starts_with("7.") {
        RouterOsMajor::V7
    } else if version.starts_with("6.") {
        RouterOsMajor::V6
    } else {
        RouterOsMajor::Unknown
    }
}

#[derive(Debug, Clone)]
pub(crate) struct ExecutionTarget {
    pub(crate) host: String,
    pub(crate) user: String,
    pub(crate) password: String,
    pub(crate) requested_protocol: String,
    pub(crate) routeros_version: String,
    pub(crate) port: u16,
}

pub(crate) fn resolve_execution_target(cli: &Cli) -> RosWireResult<ExecutionTarget> {
    let env = read_env_map();
    resolve_execution_target_with_env(cli, &env)
}

fn resolve_execution_target_with_env(
    cli: &Cli,
    env: &BTreeMap<String, String>,
) -> RosWireResult<ExecutionTarget> {
    let paths = config::ConfigPaths::from_home(config::resolve_home_path(
        env.get("ROSWIRE_HOME").map(String::as_str),
    ));
    let config_file = if paths.config.exists() {
        config::ensure_secure_directory_permissions(&paths.home)?;
        config::ensure_secure_file_permissions(&paths.config)?;
        Some(config::load_config_file(&paths.config)?)
    } else {
        None
    };

    let selected_profile = match &config_file {
        Some(config_file) => {
            match config::select_active_profile(cli.profile.as_deref(), config_file) {
                Ok(profile) => Some(profile),
                Err(error) if cli.profile.is_some() => return Err(error),
                Err(_) => None,
            }
        }
        None if cli.profile.is_some() => {
            return Err(Box::new(error::RosWireError::profile_not_found(
                cli.profile.clone().expect("profile is checked as Some"),
            )));
        }
        None => None,
    };

    let profile = config_file.as_ref().and_then(|config_file| {
        selected_profile
            .as_ref()
            .and_then(|name| config_file.profiles.get(name))
    });

    let host = cli
        .host
        .clone()
        .or_else(|| profile.and_then(|profile| profile.host.clone()))
        .ok_or_else(|| {
            Box::new(error::RosWireError::config(
                "missing RouterOS host; set --host or profile host",
            ))
        })?;
    config::validate_remote_host(&host)?;
    let user = cli
        .user
        .clone()
        .or_else(|| profile.and_then(|profile| profile.user.clone()))
        .ok_or_else(|| {
            Box::new(error::RosWireError::config(
                "missing RouterOS user; set --user or profile user",
            ))
        })?;
    let password = match cli.password.clone() {
        Some(password) => password,
        None => match profile {
            Some(profile) => config::resolve_profile_secret_value(profile, "password", env)?
                .ok_or_else(|| {
                    Box::new(error::RosWireError::config(
                        "missing RouterOS password; set --password or profile secret password",
                    ))
                })?,
            None => {
                return Err(Box::new(error::RosWireError::config(
                    "missing RouterOS password; set --password or profile secret password",
                )));
            }
        },
    };

    let requested_protocol = cli
        .protocol
        .map(|value| value.as_str().to_owned())
        .or_else(|| profile.and_then(|profile| profile.protocol.clone()))
        .unwrap_or_else(|| "auto".to_owned());
    validate_protocol(&requested_protocol)?;

    let routeros_version = cli
        .routeros_version
        .map(|value| value.as_str().to_owned())
        .or_else(|| profile.and_then(|profile| profile.routeros_version.clone()))
        .unwrap_or_else(|| "auto".to_owned());
    validate_routeros_version(&routeros_version)?;

    let explicit_port = cli
        .port
        .or_else(|| profile.and_then(|profile| profile.port));
    let port = explicit_port.unwrap_or_else(|| default_port(&requested_protocol));

    if requested_protocol == "auto" && explicit_port.is_some() {
        return Err(Box::new(error::RosWireError::config(
            "port cannot be used with --protocol auto",
        )));
    }

    Ok(ExecutionTarget {
        host,
        user,
        password,
        requested_protocol,
        routeros_version,
        port,
    })
}

fn validate_protocol(value: &str) -> RosWireResult<()> {
    match value {
        "auto" | "api" | "api-ssl" | "rest" => Ok(()),
        _ => Err(Box::new(error::RosWireError::usage(format!(
            "invalid protocol value: {value}",
        )))),
    }
}

fn validate_routeros_version(value: &str) -> RosWireResult<()> {
    match value {
        "auto" | "v6" | "v7" => Ok(()),
        _ => Err(Box::new(error::RosWireError::usage(format!(
            "invalid routeros_version value: {value}",
        )))),
    }
}

#[cfg(test)]
fn parse_port(value: &str) -> RosWireResult<u16> {
    value.parse::<u16>().map_err(|error| {
        Box::new(error::RosWireError::usage(format!(
            "invalid port value `{value}`: {error}",
        )))
    })
}

fn default_port(protocol: &str) -> u16 {
    match protocol {
        "api-ssl" => 8729,
        "rest" => 443,
        _ => 8728,
    }
}

fn read_env_map() -> BTreeMap<String, String> {
    std::env::vars().collect()
}

fn with_context<T>(result: RosWireResult<T>, context: ErrorContext) -> RosWireResult<T> {
    result.map_err(|error| Box::new((*error).clone().with_context(context)))
}

fn execution_context(
    invocation: &args::ParsedInvocation,
    target: &ExecutionTarget,
    selected_protocol: &str,
) -> ErrorContext {
    ErrorContext {
        command: mapping::command_name(invocation),
        path: invocation.path.clone(),
        action: invocation.action.clone(),
        requested_protocol: target.requested_protocol.clone(),
        selected_protocol: selected_protocol.to_owned(),
        transfer_backend: None,
        routeros_version: target.routeros_version.clone(),
        host: target.host.clone(),
        resolved_args: error::redact_resolved_args(&invocation.resolved_args),
    }
}

#[cfg(test)]
fn unsupported_command_name(invocation: &args::ParsedInvocation) -> String {
    invocation
        .path
        .iter()
        .chain(std::iter::once(&invocation.action))
        .map(String::as_str)
        .collect::<Vec<_>>()
        .join("/")
}

#[cfg(test)]
mod tests {
    use super::{
        classify_probe_error, default_port, execution_context, parse_port, render_protocol_payload,
        resolve_execution_target_with_env, routeros_major_from_rest_resource,
        routeros_major_from_version, sanitized_response_value, unsupported_command_name,
        validate_protocol, validate_raw_safety, validate_routeros_version, with_context,
        ExecutionTarget,
    };
    use crate::args::{Cli, ParsedInvocation};
    use crate::error::{ErrorCode, RosWireError};
    use crate::mapping::build_protocol_request;
    use crate::protocol::{ProbeResult, RouterOsMajor};
    use clap::Parser;
    use std::collections::BTreeMap;
    use std::fs;

    #[test]
    fn execution_target_uses_cli_values_and_defaults() {
        let cli = Cli::try_parse_from([
            "roswire",
            "--host",
            "192.0.2.1",
            "--user",
            "admin",
            "--password",
            "test-value",
            "interface",
            "print",
        ])
        .expect("cli should parse");
        let env = isolated_env();

        let target = resolve_execution_target_with_env(&cli, &env).expect("target should resolve");

        assert_eq!(target.host, "192.0.2.1");
        assert_eq!(target.user, "admin");
        assert_eq!(target.password, "test-value");
        assert_eq!(target.requested_protocol, "auto");
        assert_eq!(target.routeros_version, "auto");
        assert_eq!(target.port, 8728);
    }

    #[test]
    fn execution_target_ignores_ros_env_and_uses_profile() {
        let (temp, env_home) = temp_home_env();
        write_config(
            temp.path(),
            r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "198.51.100.10"
user = "profile-user"
protocol = "api"
routeros_version = "v6"
port = 8728
allow_plain_secrets = true

[profiles.studio.secrets.password]
type = "plain"
value = "profile-value"
"#,
        );
        let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");
        let mut env = env_home;
        env.insert("ROS_HOST".to_owned(), "203.0.113.9".to_owned());
        env.insert("ROS_USER".to_owned(), "env-user".to_owned());
        env.insert("ROS_PASSWORD".to_owned(), "env-value".to_owned());
        env.insert("ROS_PROTOCOL".to_owned(), "api-ssl".to_owned());
        env.insert("ROS_ROUTEROS_VERSION".to_owned(), "v7".to_owned());
        env.insert("ROS_PORT".to_owned(), "8729".to_owned());

        let target = resolve_execution_target_with_env(&cli, &env).expect("target should resolve");

        assert_eq!(target.host, "198.51.100.10");
        assert_eq!(target.user, "profile-user");
        assert_eq!(target.password, "profile-value");
        assert_eq!(target.requested_protocol, "api");
        assert_eq!(target.routeros_version, "v6");
        assert_eq!(target.port, 8728);
    }

    #[test]
    fn execution_target_rejects_mac_host_before_network() {
        let (temp, env) = temp_home_env();
        write_config(
            temp.path(),
            r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "48-8F-5A-A3-0E-A7"
user = "profile-user"
"#,
        );
        let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");

        let error = resolve_execution_target_with_env(&cli, &env)
            .expect_err("MAC host should fail before any network connection");

        assert_eq!(error.error_code, ErrorCode::ConfigError);
        assert_eq!(error.context.host, "48-8F-5A-A3-0E-A7");
        assert!(error.message.contains("MAC address"));
    }

    #[test]
    fn execution_target_resolves_profile_plain_and_same_as_secret() {
        let (temp, env) = temp_home_env();
        write_config(
            temp.path(),
            r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "198.51.100.10"
user = "profile-user"
protocol = "api"
routeros_version = "v7"
allow_plain_secrets = true

[profiles.studio.secrets.actual]
type = "plain"
value = "profile-value"

[profiles.studio.secrets.password]
type = "same-as"
target = "actual"
"#,
        );
        let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");

        let target = resolve_execution_target_with_env(&cli, &env).expect("target should resolve");

        assert_eq!(target.host, "198.51.100.10");
        assert_eq!(target.user, "profile-user");
        assert_eq!(target.password, "profile-value");
        assert_eq!(target.requested_protocol, "api");
        assert_eq!(target.routeros_version, "v7");
        assert_eq!(target.port, 8728);
    }

    #[test]
    fn execution_target_rejects_auto_with_explicit_port() {
        let cli = Cli::try_parse_from([
            "roswire",
            "--host",
            "192.0.2.1",
            "--user",
            "admin",
            "--password",
            "test-value",
            "--port",
            "8728",
            "interface",
            "print",
        ])
        .expect("cli should parse");
        let env = isolated_env();

        let error =
            resolve_execution_target_with_env(&cli, &env).expect_err("auto port should fail");

        assert_eq!(error.error_code, ErrorCode::ConfigError);
    }

    #[test]
    fn execution_target_reports_missing_env_secret() {
        let (temp, env) = temp_home_env();
        write_config(
            temp.path(),
            r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "198.51.100.10"
user = "profile-user"

[profiles.studio.secrets.password]
type = "env"
var = "ROSWIRE_TEST_PASSWORD"
"#,
        );
        let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");

        let error = resolve_execution_target_with_env(&cli, &env)
            .expect_err("missing env secret should fail");

        assert_eq!(error.error_code, ErrorCode::SecretNotFound);
        assert!(error.message.contains("ROSWIRE_TEST_PASSWORD"));
    }

    #[test]
    fn execution_target_reports_missing_encrypted_master_key() {
        let (temp, env) = temp_home_env();
        write_config(
            temp.path(),
            r#"
version = 1
default_profile = "studio"

[profiles.studio]
host = "198.51.100.10"
user = "profile-user"

[profiles.studio.secrets.password]
type = "encrypted"
value = "v1:nonce:ciphertext"
"#,
        );
        let cli = Cli::try_parse_from(["roswire", "interface", "print"]).expect("cli should parse");

        let error = resolve_execution_target_with_env(&cli, &env)
            .expect_err("missing master key should fail");

        assert_eq!(error.error_code, ErrorCode::SecretBackendUnavailable);
        assert!(error.message.contains("ROSWIRE_MASTER_KEY"));
    }

    #[test]
    fn validation_helpers_cover_protocol_version_and_ports() {
        validate_protocol("auto").expect("auto protocol is valid");
        validate_protocol("api").expect("api protocol is valid");
        assert_eq!(
            validate_protocol("bogus")
                .expect_err("invalid protocol should fail")
                .error_code,
            ErrorCode::UsageError,
        );
        validate_routeros_version("v6").expect("v6 is valid");
        assert_eq!(
            validate_routeros_version("v8")
                .expect_err("invalid version should fail")
                .error_code,
            ErrorCode::UsageError,
        );
        assert_eq!(parse_port("8728").expect("port should parse"), 8728);
        assert_eq!(
            parse_port("bad")
                .expect_err("bad port should fail")
                .error_code,
            ErrorCode::UsageError,
        );
        assert_eq!(default_port("api"), 8728);
        assert_eq!(default_port("api-ssl"), 8729);
        assert_eq!(default_port("rest"), 443);
    }

    #[test]
    fn rest_resource_version_helpers_detect_routeros_major() {
        assert_eq!(routeros_major_from_version("7.15.3"), RouterOsMajor::V7);
        assert_eq!(routeros_major_from_version("6.49.10"), RouterOsMajor::V6);
        assert_eq!(
            routeros_major_from_version("unknown"),
            RouterOsMajor::Unknown
        );

        assert_eq!(
            routeros_major_from_rest_resource(&serde_json::json!({"version":"7.16"})),
            RouterOsMajor::V7,
        );
        assert_eq!(
            routeros_major_from_rest_resource(&serde_json::json!({"board-name":"RB5009"})),
            RouterOsMajor::Unknown,
        );
    }

    #[test]
    fn probe_error_classifier_preserves_auth_failures_only() {
        assert_eq!(
            classify_probe_error(&RosWireError::auth_failed("bad login")),
            ProbeResult::AuthFailed,
        );
        assert_eq!(
            classify_probe_error(&RosWireError::network("timeout")),
            ProbeResult::NetworkFailure,
        );
        assert_eq!(
            classify_probe_error(&RosWireError::ros_api_failure("trap")),
            ProbeResult::NetworkFailure,
        );
    }

    #[test]
    fn write_payload_wraps_success_response_with_stable_metadata() {
        let request = build_protocol_request(&ParsedInvocation {
            path: vec!["ip".to_owned(), "address".to_owned()],
            action: "add".to_owned(),
            resolved_args: BTreeMap::from([
                ("address".to_owned(), "192.0.2.10/24".to_owned()),
                ("interface".to_owned(), "bridge".to_owned()),
            ]),
            flags: Vec::new(),
        })
        .expect("write request should map");
        let rows = Vec::<BTreeMap<String, String>>::new();

        let payload =
            render_protocol_payload(&request, "api-ssl", &rows).expect("payload should serialize");

        assert!(
            payload.contains("\"schema_version\": \"roswire.write.v1\""),
            "{payload}"
        );
        assert!(payload.contains("\"command\": \"ip/address/add\""));
        assert!(payload.contains("\"selected_protocol\": \"api-ssl\""));
        assert!(payload.contains("\"response\": []"));
    }

    #[test]
    fn write_payload_redacts_sensitive_response_fields() {
        let request = build_protocol_request(&ParsedInvocation {
            path: vec!["system".to_owned(), "script".to_owned()],
            action: "add".to_owned(),
            resolved_args: BTreeMap::from([
                ("name".to_owned(), "bootstrap".to_owned()),
                ("source".to_owned(), ":put secret".to_owned()),
            ]),
            flags: Vec::new(),
        })
        .expect("script write request should map");
        let response = serde_json::json!({
            "name": "bootstrap",
            "source": ":put secret",
            "nested": { "password": "super-secret" }
        });

        let payload =
            render_protocol_payload(&request, "rest", &response).expect("payload should serialize");
        let sanitized = sanitized_response_value(&response).expect("response should sanitize");

        assert_eq!(sanitized["source"], "***REDACTED***");
        assert_eq!(sanitized["nested"]["password"], "***REDACTED***");
        assert!(payload.contains("\"schema_version\": \"roswire.write.v1\""));
        assert!(!payload.contains(":put secret"));
        assert!(!payload.contains("super-secret"));
    }

    #[test]
    fn raw_write_requires_explicit_allow_write() {
        let cli = Cli::try_parse_from([
            "roswire",
            "raw",
            "/tool/fetch",
            "password=super-secret",
            "src-path=/Users/example/setup.rsc",
        ])
        .expect("cli should parse");
        let invocation = ParsedInvocation {
            path: vec!["raw".to_owned()],
            action: "/tool/fetch".to_owned(),
            resolved_args: BTreeMap::from([
                ("password".to_owned(), "super-secret".to_owned()),
                ("src-path".to_owned(), "/Users/example/setup.rsc".to_owned()),
            ]),
            flags: Vec::new(),
        };
        let request = build_protocol_request(&invocation).expect("raw request should map");

        let error = validate_raw_safety(&invocation, &request, &cli)
            .expect_err("raw write should require allow-write");

        assert_eq!(error.error_code, ErrorCode::UsageError);
        assert_eq!(
            error
                .context
                .resolved_args
                .get("password")
                .map(String::as_str),
            Some("***REDACTED***"),
        );
        assert_eq!(
            error
                .context
                .resolved_args
                .get("src-path")
                .map(String::as_str),
            Some("***REDACTED***/setup.rsc"),
        );
    }

    #[test]
    fn raw_write_payload_uses_routeros_path_as_command() {
        let request = build_protocol_request(&ParsedInvocation {
            path: vec!["raw".to_owned()],
            action: "/tool/fetch".to_owned(),
            resolved_args: BTreeMap::from([(
                "url".to_owned(),
                "https://example.invalid/a.rsc".to_owned(),
            )]),
            flags: Vec::new(),
        })
        .expect("raw write request should map");
        let response = serde_json::json!({"status":"ok"});

        let payload = render_protocol_payload(&request, "api", &response)
            .expect("raw write payload should serialize");

        assert!(payload.contains("\"schema_version\": \"roswire.write.v1\""));
        assert!(payload.contains("\"command\": \"tool/fetch\""));
        assert!(payload.contains("\"action\": \"raw\""));
    }

    #[test]
    fn context_helpers_attach_execution_details() {
        let invocation = ParsedInvocation {
            path: vec!["ip".to_owned(), "address".to_owned()],
            action: "print".to_owned(),
            resolved_args: BTreeMap::from([("password".to_owned(), "test-value".to_owned())]),
            flags: Vec::new(),
        };
        let target = ExecutionTarget {
            host: "router.local".to_owned(),
            user: "admin".to_owned(),
            password: "test-value".to_owned(),
            requested_protocol: "auto".to_owned(),
            routeros_version: "v7".to_owned(),
            port: 8728,
        };

        let context = execution_context(&invocation, &target, "api");
        assert_eq!(unsupported_command_name(&invocation), "ip/address/print");
        assert_eq!(context.command, "ip/address/print");
        assert_eq!(context.selected_protocol, "api");
        assert_eq!(
            context.resolved_args.get("password").map(String::as_str),
            Some("***REDACTED***"),
        );

        let error = with_context::<()>(
            Err(Box::new(RosWireError::network("unreachable"))),
            context.clone(),
        )
        .expect_err("context should attach");
        assert_eq!(error.context.command, context.command);
    }

    fn isolated_env() -> BTreeMap<String, String> {
        let (temp, mut env) = temp_home_env();
        env.insert(
            "ROSWIRE_HOME".to_owned(),
            temp.path().join("missing-home").display().to_string(),
        );
        env
    }

    fn temp_home_env() -> (tempfile::TempDir, BTreeMap<String, String>) {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let env = BTreeMap::from([("ROSWIRE_HOME".to_owned(), temp.path().display().to_string())]);
        (temp, env)
    }

    fn write_config(home: &std::path::Path, contents: &str) {
        fs::write(home.join("config.toml"), contents).expect("config should be written");
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            fs::set_permissions(home, fs::Permissions::from_mode(0o700))
                .expect("home permissions should be set");
            fs::set_permissions(home.join("config.toml"), fs::Permissions::from_mode(0o600))
                .expect("config permissions should be set");
        }
    }
}
