use super::*;
use crate::test_support::temp_dir;
use std::fs;

const ROOT_A: &str = "be2us-64aaa-aaaaa-qaabq-cai";
const GOVERNANCE_A: &str = "bkyz2-fmaaa-aaaaa-qaaaq-cai";
const LEDGER_A: &str = "bd3sg-teaaa-aaaaa-qaaba-cai";
const SWAP_A: &str = "br5f7-7uaaa-aaaaa-qaaca-cai";
const INDEX_A: &str = "bw4dl-smaaa-aaaaa-qaacq-cai";
const ROOT_B: &str = "bd3sg-teaaa-aaaaa-qaaba-cai";
const GOVERNANCE_B: &str = "br5f7-7uaaa-aaaaa-qaaca-cai";
const LEDGER_B: &str = "bw4dl-smaaa-aaaaa-qaacq-cai";
const SWAP_B: &str = "ryjl3-tyaaa-aaaaa-aaaba-cai";
const INDEX_B: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";

#[test]
fn sns_list_report_uses_names_and_compact_ids_by_default() {
    let request = list_request(false);

    let report = build_sns_list_report_with_source(&request, &FixtureSnsListSource)
        .expect("sns list report");
    let text = sns_list_report_text(&report);

    assert_eq!(report.schema_version, SNS_LIST_REPORT_SCHEMA_VERSION);
    assert_eq!(report.network, MAINNET_NETWORK);
    assert_eq!(report.sns_wasm_canister_id, MAINNET_SNS_WASM_CANISTER_ID);
    assert_eq!(report.sns_count, 1);
    assert!(!report.verbose);
    assert_eq!(report.sort, "id");
    assert_eq!(report.sns_instances[0].id, 1);
    assert_eq!(report.sns_instances[0].name, "Fixture SNS");
    assert_eq!(report.sns_instances[0].root_canister_id, ROOT_A);
    assert_eq!(report.metadata_error_count, 0);
    assert_eq!(report.sns_instances[0].metadata_error, None);
    assert!(text.contains("ID   NAME"));
    assert!(text.contains("NAME"));
    assert!(text.contains("sort: id"));
    assert!(text.contains("metadata_errors: 0"));
    assert!(text.contains("Fixture SNS"));
    assert!(text.contains(&ROOT_A[..COMPACT_PRINCIPAL_CHARS]));
    assert!(!text.contains(ROOT_A));
}

#[test]
fn sns_list_report_verbose_text_keeps_full_ids() {
    let request = list_request(true);

    let report = build_sns_list_report_with_source(&request, &FixtureSnsListSource)
        .expect("sns list report");
    let text = sns_list_report_text(&report);

    assert!(report.verbose);
    assert!(text.contains(ROOT_A));
    assert!(text.contains(GOVERNANCE_A));
}

#[test]
fn sns_info_resolves_list_id() {
    let request = info_request("1");

    let report = build_sns_info_report_with_source(&request, &FixtureSnsListSource)
        .expect("sns info report");
    let text = sns_info_report_text(&report);

    assert_eq!(report.schema_version, SNS_INFO_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.description.as_deref(), Some("Fixture description"));
    assert_eq!(report.url.as_deref(), Some("https://example.com"));
    assert_eq!(report.metadata_error, None);
    assert!(text.contains("root_canister_id: be2us-64aaa-aaaaa-qaabq-cai"));
}

#[test]
fn sns_info_resolves_root_principal() {
    let request = info_request(ROOT_A);

    let report = build_sns_info_report_with_source(&request, &FixtureSnsListSource)
        .expect("sns info report");

    assert_eq!(report.id, 1);
    assert_eq!(report.root_canister_id, ROOT_A);
}

#[test]
fn sns_token_resolves_list_id_and_renders_ledger_metadata() {
    let request = token_request("1");

    let report = build_sns_token_report_with_source(&request, &FixtureSnsTokenSource)
        .expect("sns token report");
    let text = sns_token_report_text(&report);

    assert_eq!(report.schema_version, SNS_TOKEN_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.root_canister_id, ROOT_A);
    assert_eq!(report.ledger_canister_id, LEDGER_A);
    assert_eq!(report.sns_index_canister_id, INDEX_A);
    assert_eq!(report.ledger_index_canister_id.as_deref(), Some(INDEX_A));
    assert_eq!(report.token_name, "Fixture Token");
    assert_eq!(report.token_symbol, "FIX");
    assert_eq!(report.decimals, 8);
    assert_eq!(report.transfer_fee, "10_000");
    assert_eq!(report.total_supply, "1_000_000_000");
    assert_eq!(report.minting_account_owner.as_deref(), Some(GOVERNANCE_A));
    assert_eq!(
        report.minting_account_subaccount_hex.as_deref(),
        Some("000102")
    );
    assert_eq!(report.supported_standards[0].name, "ICRC-1");
    assert_eq!(report.metadata[0].key, "icrc1:name");
    assert!(report.metadata.iter().any(|row| row.key == "icrc1:logo"
        && row.value_type == "bool"
        && row.value == serde_json::json!(true)));
    assert!(text.contains("token_symbol: FIX"));
    assert!(text.contains("transfer_fee: 0.00"));
    assert!(text.contains("total_supply: 10.00"));
    assert!(!text.contains("10_000"));
    assert!(!text.contains("1_000_000_000"));
    assert!(text.contains("ledger_index_canister_id: bw4dl-smaaa-aaaaa-qaacq-cai"));
    assert!(text.contains("ICRC-1"));
    assert!(text.contains("icrc1:name"));
    assert!(text.contains("icrc1:fee"));
    assert!(text.contains("icrc1:logo"));
    assert!(text.contains("true"));
    assert!(!text.contains("data:image"));
}

#[test]
fn sns_token_logo_metadata_is_presence_boolean() {
    let row = metadata_row(
        SNS_TOKEN_LOGO_METADATA_KEY.to_string(),
        IcrcMetadataValue::Text("data:image/png;base64,large-logo".to_string()),
    );

    assert_eq!(row.key, SNS_TOKEN_LOGO_METADATA_KEY);
    assert_eq!(row.value_type, "bool");
    assert_eq!(row.value, serde_json::json!(true));
}

#[test]
fn sns_token_empty_logo_metadata_is_false() {
    let row = metadata_row(
        SNS_TOKEN_LOGO_METADATA_KEY.to_string(),
        IcrcMetadataValue::Text(" ".to_string()),
    );

    assert_eq!(row.value_type, "bool");
    assert_eq!(row.value, serde_json::json!(false));
}

#[test]
fn sns_params_resolves_list_id_and_renders_governance_parameters() {
    let request = params_request("1");

    let report = build_sns_params_report_with_source(&request, &FixtureSnsParamsSource)
        .expect("sns params report");
    let text = sns_params_report_text(&report);

    assert_eq!(report.schema_version, SNS_PARAMS_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.root_canister_id, ROOT_A);
    assert_eq!(report.governance_canister_id, GOVERNANCE_A);
    assert_eq!(
        report.parameters.neuron_minimum_stake_e8s,
        Some(100_000_000)
    );
    assert_eq!(report.parameters.transaction_fee_e8s, Some(10_000));
    assert_eq!(
        report
            .parameters
            .voting_rewards_parameters
            .as_ref()
            .and_then(|rewards| rewards.initial_reward_rate_basis_points),
        Some(1000)
    );
    assert!(text.contains("governance_canister_id: bkyz2-fmaaa-aaaaa-qaaaq-cai"));
    assert!(text.contains("neuron_minimum_stake"));
    assert!(text.contains("transaction_fee"));
    assert!(text.contains("max_dissolve_delay"));
    assert!(text.contains("voting_reward_initial_rate"));
    assert!(text.contains("automatically_advance_target_version"));
    assert!(text.contains("1.00"));
    assert!(text.contains("0.00"));
    assert!(text.contains("2922d"));
    assert!(text.contains("10.00%"));
    assert!(text.contains("yes"));
    assert!(text.contains("1,2,3"));
}

#[test]
fn sns_proposal_resolves_list_id_and_renders_governance_proposal() {
    let request = proposal_request("1");

    let report = build_sns_proposal_report_with_source(&request, &FixtureSnsProposalSource)
        .expect("sns proposal report");
    let text = sns_proposal_report_text(&report);

    assert_eq!(report.schema_version, SNS_PROPOSAL_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.root_canister_id, ROOT_A);
    assert_eq!(report.governance_canister_id, GOVERNANCE_A);
    assert_eq!(report.proposal_id, 42);
    assert_eq!(report.proposal.proposal_id, Some(42));
    assert_eq!(report.proposal.action, "motion");
    assert_eq!(report.proposal.decision_state, "open");
    assert_eq!(report.proposal.ballot_count, 1);
    assert_eq!(report.proposal.ballots[0].vote_text, "yes");
    assert!(text.contains("proposal_id: 42"));
    assert!(text.contains("action: motion"));
    assert!(text.contains("ballot_count: 1"));
    assert!(text.contains("Fixture proposal"));
    assert!(text.contains("reject_cost: 1.00"));
}

#[test]
fn sns_proposals_resolves_list_id_and_renders_governance_proposals() {
    let request = proposals_request("1");

    let report = build_sns_proposals_report_with_source(&request, &FixtureSnsProposalsSource)
        .expect("sns proposals report");
    let text = sns_proposals_report_text(&report);

    assert_eq!(report.schema_version, SNS_PROPOSALS_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.root_canister_id, ROOT_A);
    assert_eq!(report.governance_canister_id, GOVERNANCE_A);
    assert_eq!(report.requested_limit, 10);
    assert_eq!(report.before_proposal_id, Some(99));
    assert_eq!(report.status_filter, "open");
    assert_eq!(report.proposal_count, 1);
    assert_eq!(report.proposals[0].proposal_id, Some(42));
    assert_eq!(report.proposals[0].action, "motion");
    assert_eq!(report.proposals[0].decision_state, "open");
    assert_eq!(report.proposals[0].reject_cost_e8s, 100_000_000);
    assert_eq!(report.proposals[0].created_at, "2026-06-01T00:00:00Z");
    assert_eq!(
        report.proposals[0]
            .latest_tally
            .as_ref()
            .map(|tally| tally.yes),
        Some(10)
    );
    assert!(text.contains("status_filter: open"));
    assert!(text.contains("before_proposal_id: 99"));
    assert!(text.contains("proposal_count: 1"));
    assert!(text.contains("ID   ACTION"));
    assert!(text.contains("motion"));
    assert!(text.contains("Fixture proposal"));
}

#[test]
fn sns_neurons_resolves_list_id_and_renders_governance_neurons() {
    let mut request = neurons_request("1");
    request.owner_principal_id = Some(GOVERNANCE_A.to_string());

    let report = build_sns_neurons_report_with_source(&request, &FixtureSnsNeuronsSource)
        .expect("sns neurons report");
    let text = sns_neurons_report_text(&report);

    assert_eq!(report.schema_version, SNS_NEURONS_REPORT_SCHEMA_VERSION);
    assert_eq!(report.id, 1);
    assert_eq!(report.name, "Fixture SNS");
    assert_eq!(report.root_canister_id, ROOT_A);
    assert_eq!(report.governance_canister_id, GOVERNANCE_A);
    assert_eq!(report.requested_limit, 10);
    assert_eq!(report.owner_principal_id.as_deref(), Some(GOVERNANCE_A));
    assert_eq!(report.neuron_count, 1);
    assert_eq!(report.neurons[0].neuron_id, "0001020304");
    assert_eq!(report.neurons[0].cached_neuron_stake_e8s, 123);
    assert_eq!(report.neurons[0].maturity_e8s_equivalent, 456);
    assert_eq!(report.neurons[0].staked_maturity_e8s_equivalent, Some(789));
    assert_eq!(report.neurons[0].created_at, "2026-06-01T00:00:00Z");
    assert!(text.contains("governance_canister_id: bkyz2-fmaaa-aaaaa-qaaaq-cai"));
    assert!(text.contains("requested_limit: 10"));
    assert!(text.contains("owner_principal_id: bkyz2-fmaaa-aaaaa-qaaaq-cai"));
    assert!(text.contains("00010203"));
    assert!(!text.contains("0001020304"));
    assert!(text.contains("STAKE"));
    assert!(text.contains("MATURITY"));
    assert!(text.contains("STAKED_MATURITY"));
    assert!(!text.contains("STAKE_E8S"));
    assert!(!text.contains("MATURITY_E8S"));
    assert!(text.contains("0.00"));
    assert!(text.contains("2026-06-01T00:00:00Z"));
}

#[test]
fn sns_neurons_text_formats_optional_e8s_as_token_decimals() {
    assert_eq!(text::optional_e8s_decimal_text(None), "-");
    assert_eq!(text::optional_e8s_decimal_text(Some(50_000_000)), "0.50");
}

#[test]
fn sns_neurons_verbose_text_keeps_full_neuron_ids() {
    let mut request = neurons_request("1");
    request.owner_principal_id = Some(GOVERNANCE_A.to_string());
    request.verbose = true;

    let report = build_sns_neurons_report_with_source(&request, &FixtureSnsNeuronsSource)
        .expect("sns neurons report");
    let text = sns_neurons_report_text(&report);

    assert!(report.verbose);
    assert!(text.contains("verbose: yes"));
    assert!(text.contains("0001020304"));
}

#[test]
fn sns_neurons_refresh_writes_complete_cache_and_cached_sort_uses_it() {
    let root = temp_dir("ic-query-sns-neurons-refresh");
    let request = sns_neurons_refresh_request(&root, None);

    let refresh = refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect("refresh neurons");
    let cache_path = sns_neurons_cache_path(&root, MAINNET_NETWORK, ROOT_A);
    let attempt_path = sns_neurons_refresh_attempt_path(&root, MAINNET_NETWORK, ROOT_A);
    let lock_path = sns_neurons_refresh_lock_path(&root, MAINNET_NETWORK, ROOT_A);

    assert!(cache_path.is_file());
    assert!(attempt_path.is_file());
    assert!(!lock_path.exists());
    assert!(refresh.complete);
    assert_eq!(refresh.page_count, 3);
    assert_eq!(refresh.neuron_count, 3);

    let mut cached_request = neurons_request("1");
    cached_request.icp_root = Some(root.clone());
    cached_request.sort = SnsNeuronsSort::Stake;
    cached_request.limit = 2;
    let report = build_sns_neurons_report_with_source(&cached_request, &NoLiveSnsNeuronsSource)
        .expect("cached neurons report");

    assert_eq!(report.data_source, "cache");
    assert_eq!(report.sort, "stake");
    assert_eq!(report.total_neuron_count, 3);
    assert_eq!(report.neuron_count, 2);
    assert_eq!(report.neurons[0].neuron_id, "03");
    assert_eq!(report.neurons[0].cached_neuron_stake_e8s, 50);
    assert_eq!(report.neurons[1].neuron_id, "02");
    assert_eq!(report.neurons[1].cached_neuron_stake_e8s, 30);

    let attempt: serde_json::Value =
        serde_json::from_slice(&fs::read(attempt_path).expect("read attempt"))
            .expect("parse attempt");
    assert_eq!(attempt["status"], "complete");

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_cache_list_and_status_reports_complete_snapshot() {
    let root = temp_dir("ic-query-sns-neurons-cache-status");
    let request = sns_neurons_refresh_request(&root, None);

    refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect("refresh neurons");

    let list = build_sns_neurons_cache_list_report(&SnsNeuronsCacheListRequest {
        network: MAINNET_NETWORK.to_string(),
        icp_root: root.clone(),
    })
    .expect("cache list");
    let list_text = sns_neurons_cache_list_report_text(&list);

    assert_eq!(
        list.schema_version,
        SNS_NEURONS_CACHE_LIST_REPORT_SCHEMA_VERSION
    );
    assert_eq!(list.cache_count, 1);
    assert_eq!(list.caches[0].id, 1);
    assert_eq!(list.caches[0].name, "Fixture SNS");
    assert_eq!(list.caches[0].row_count, 3);
    assert_eq!(list.caches[0].page_count, 3);
    assert!(list.caches[0].complete);
    assert_eq!(
        list.caches[0]
            .latest_attempt
            .as_ref()
            .map(|attempt| attempt.status.as_str()),
        Some("complete")
    );
    assert!(list_text.contains("cache_count: 1"));
    assert!(list_text.contains("Fixture SNS"));

    let status = build_sns_neurons_cache_status_report(&SnsNeuronsCacheStatusRequest {
        network: MAINNET_NETWORK.to_string(),
        icp_root: root.clone(),
        input: "1".to_string(),
    })
    .expect("cache status");
    let status_text = sns_neurons_cache_status_report_text(&status);

    assert_eq!(
        status.schema_version,
        SNS_NEURONS_CACHE_STATUS_REPORT_SCHEMA_VERSION
    );
    assert!(status.found);
    assert!(status.expected_cache_path.is_none());
    assert_eq!(
        status.cache.as_ref().expect("cache").root_canister_id,
        ROOT_A
    );
    assert_eq!(
        status
            .latest_attempt
            .as_ref()
            .map(|attempt| attempt.status.as_str()),
        Some("complete")
    );
    assert!(status_text.contains("found: yes"));
    assert!(status_text.contains("cache_path:"));
    assert!(status_text.contains("latest_attempt_status: complete"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_cache_status_reports_failed_attempt_without_complete_cache() {
    let root = temp_dir("ic-query-sns-neurons-cache-failed-status");
    let request = sns_neurons_refresh_request(&root, Some(1));

    refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect_err("incomplete refresh");

    let status = build_sns_neurons_cache_status_report(&SnsNeuronsCacheStatusRequest {
        network: MAINNET_NETWORK.to_string(),
        icp_root: root.clone(),
        input: ROOT_A.to_string(),
    })
    .expect("cache status");
    let status_text = sns_neurons_cache_status_report_text(&status);

    assert!(!status.found);
    assert!(status.cache.is_none());
    assert!(
        status
            .expected_cache_path
            .as_ref()
            .is_some_and(|path| path.contains(ROOT_A))
    );
    assert_eq!(
        status
            .latest_attempt
            .as_ref()
            .map(|attempt| attempt.status.as_str()),
        Some("failed")
    );
    assert_eq!(
        status
            .latest_attempt
            .as_ref()
            .map(|attempt| attempt.rows_fetched),
        Some(2)
    );
    assert!(status_text.contains("found: no"));
    assert!(status_text.contains("refresh_hint: icq sns neurons refresh"));
    assert!(status_text.contains("latest_attempt_status: failed"));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_refresh_max_pages_does_not_publish_incomplete_cache() {
    let root = temp_dir("ic-query-sns-neurons-incomplete-refresh");
    let request = sns_neurons_refresh_request(&root, Some(1));

    let err = refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect_err("incomplete refresh");

    assert!(matches!(
        err,
        SnsHostError::IncompleteRefresh {
            pages_fetched: 1,
            rows_fetched: 2,
            ..
        }
    ));
    assert!(!sns_neurons_cache_path(&root, MAINNET_NETWORK, ROOT_A).exists());
    let attempt_path = sns_neurons_refresh_attempt_path(&root, MAINNET_NETWORK, ROOT_A);
    assert!(attempt_path.is_file());

    let attempt: serde_json::Value =
        serde_json::from_slice(&fs::read(attempt_path).expect("read attempt"))
            .expect("parse attempt");
    assert_eq!(attempt["status"], "failed");
    assert_eq!(attempt["pages_fetched"], 1);
    assert_eq!(attempt["rows_fetched"], 2);
    assert_eq!(attempt["last_cursor"], "02");
    assert!(
        attempt["last_error"]
            .as_str()
            .expect("last error")
            .contains("max pages reached before API exhaustion")
    );

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_refresh_failure_preserves_existing_complete_cache() {
    let root = temp_dir("ic-query-sns-neurons-refresh-preserves-cache");
    let complete_request = sns_neurons_refresh_request(&root, None);
    refresh_sns_neurons_cache_with_source(&complete_request, &PagedFixtureSnsNeuronsSource)
        .expect("complete refresh");

    let failed_request = sns_neurons_refresh_request(&root, Some(1));
    refresh_sns_neurons_cache_with_source(&failed_request, &PagedFixtureSnsNeuronsSource)
        .expect_err("incomplete refresh");

    let mut cached_request = neurons_request("1");
    cached_request.icp_root = Some(root.clone());
    cached_request.sort = SnsNeuronsSort::Stake;
    let report = build_sns_neurons_report_with_source(&cached_request, &NoLiveSnsNeuronsSource)
        .expect("previous complete cache remains usable");

    assert_eq!(report.data_source, "cache");
    assert_eq!(report.total_neuron_count, 3);
    assert_eq!(report.neurons[0].neuron_id, "03");

    let attempt_path = sns_neurons_refresh_attempt_path(&root, MAINNET_NETWORK, ROOT_A);
    let attempt: serde_json::Value =
        serde_json::from_slice(&fs::read(attempt_path).expect("read attempt"))
            .expect("parse attempt");
    assert_eq!(attempt["status"], "failed");

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_cached_sort_rejects_unsupported_cache_schema() {
    let root = temp_dir("ic-query-sns-neurons-unsupported-schema");
    let request = sns_neurons_refresh_request(&root, None);
    refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect("refresh neurons");

    let cache_path = sns_neurons_cache_path(&root, MAINNET_NETWORK, ROOT_A);
    let mut cache: serde_json::Value =
        serde_json::from_slice(&fs::read(&cache_path).expect("read cache")).expect("parse cache");
    cache["schema_version"] = serde_json::json!(999);
    fs::write(
        &cache_path,
        serde_json::to_vec_pretty(&cache).expect("serialize cache"),
    )
    .expect("write cache");

    let mut cached_request = neurons_request("1");
    cached_request.icp_root = Some(root.clone());
    cached_request.sort = SnsNeuronsSort::Stake;
    let err = build_sns_neurons_report_with_source(&cached_request, &NoLiveSnsNeuronsSource)
        .expect_err("unsupported schema rejected");

    assert!(matches!(
        err,
        SnsHostError::UnsupportedCacheSchemaVersion {
            version: 999,
            expected: SNS_NEURONS_CACHE_SCHEMA_VERSION,
        }
    ));

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_neurons_refresh_removes_stale_lock_and_publishes_cache() {
    let root = temp_dir("ic-query-sns-neurons-stale-lock");
    let request = sns_neurons_refresh_request(&root, None);
    let cache_path = sns_neurons_cache_path(&root, MAINNET_NETWORK, ROOT_A);
    let lock_path = sns_neurons_refresh_lock_path(&root, MAINNET_NETWORK, ROOT_A);
    fs::create_dir_all(lock_path.parent().expect("lock path parent")).expect("create cache dir");
    fs::write(
        &lock_path,
        serde_json::to_vec_pretty(&serde_json::json!({
            "schema_version": 1,
            "network": MAINNET_NETWORK,
            "pid": 999_999,
            "started_at_unix_ms": 1,
            "target_path": cache_path.display().to_string(),
        }))
        .expect("serialize stale lock"),
    )
    .expect("write stale lock");

    let refresh = refresh_sns_neurons_cache_with_source(&request, &PagedFixtureSnsNeuronsSource)
        .expect("refresh with stale lock");

    assert!(refresh.complete);
    assert!(cache_path.is_file());
    assert!(!lock_path.exists());

    let _ = fs::remove_dir_all(root);
}

#[test]
fn sns_list_ids_follow_source_order() {
    let request = list_request(false);

    let report = build_sns_list_report_with_source(&request, &UnsortedFixtureSnsListSource)
        .expect("sns list report");
    let info = build_sns_info_report_with_source(&info_request("1"), &UnsortedFixtureSnsListSource)
        .expect("sns info report");

    assert_eq!(report.sns_instances[0].id, 1);
    assert_eq!(report.sns_instances[0].name, "A Name");
    assert_eq!(report.sns_instances[0].root_canister_id, ROOT_A);
    assert_eq!(report.sns_instances[1].id, 2);
    assert_eq!(report.sns_instances[1].name, "Z Name");
    assert_eq!(report.sns_instances[1].root_canister_id, ROOT_B);
    assert_eq!(info.id, 1);
    assert_eq!(info.root_canister_id, ROOT_A);
}

#[test]
fn sns_list_name_sort_keeps_stable_ids() {
    let mut request = list_request(false);
    request.sort = SnsListSort::Name;

    let report = build_sns_list_report_with_source(&request, &UnsortedFixtureSnsListSource)
        .expect("sns list report");
    let text = sns_list_report_text(&report);
    let info = build_sns_info_report_with_source(&info_request("1"), &UnsortedFixtureSnsListSource)
        .expect("sns info report");

    assert_eq!(report.sort, "name");
    assert_eq!(report.sns_instances[0].id, 1);
    assert_eq!(report.sns_instances[0].name, "A Name");
    assert_eq!(report.sns_instances[1].id, 2);
    assert_eq!(report.sns_instances[1].name, "Z Name");
    assert!(text.contains("sort: name"));
    assert_eq!(info.id, 1);
    assert_eq!(info.root_canister_id, ROOT_A);
}

#[test]
fn sns_list_surfaces_metadata_fallbacks() {
    let request = list_request(true);

    let report = build_sns_list_report_with_source(&request, &MetadataErrorFixtureSnsListSource)
        .expect("sns list report");
    let text = sns_list_report_text(&report);
    let info =
        build_sns_info_report_with_source(&info_request("1"), &MetadataErrorFixtureSnsListSource)
            .expect("sns info report");
    let info_text = sns_info_report_text(&info);

    assert_eq!(report.metadata_error_count, 1);
    assert_eq!(
        report.sns_instances[0].metadata_error.as_deref(),
        Some("get_metadata: Canister has no Wasm module")
    );
    assert!(text.contains("metadata_errors: 1"));
    assert!(text.contains("metadata_error_details:"));
    assert!(text.contains("get_metadata: Canister has no Wasm module"));
    assert_eq!(
        info.metadata_error.as_deref(),
        Some("get_metadata: Canister has no Wasm module")
    );
    assert!(info_text.contains("metadata_error: get_metadata: Canister has no Wasm module"));
}

#[test]
fn sns_info_rejects_unknown_id() {
    let request = info_request("2");

    let err =
        build_sns_info_report_with_source(&request, &FixtureSnsListSource).expect_err("unknown id");

    assert!(matches!(
        err,
        SnsHostError::UnknownSnsId {
            id: 2,
            sns_count: 1
        }
    ));
}

#[test]
fn sns_list_rejects_local_network() {
    let request = SnsListRequest {
        network: "local".to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        verbose: false,
        sort: SnsListSort::Id,
    };

    let err = build_sns_list_report_with_source(&request, &FixtureSnsListSource)
        .expect_err("local rejected");

    assert!(matches!(err, SnsHostError::UnsupportedNetwork { .. }));
}

fn list_request(verbose: bool) -> SnsListRequest {
    SnsListRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        verbose,
        sort: SnsListSort::Id,
    }
}

fn info_request(input: &str) -> SnsInfoRequest {
    SnsInfoRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
    }
}

fn token_request(input: &str) -> SnsTokenRequest {
    SnsTokenRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
    }
}

fn params_request(input: &str) -> SnsParamsRequest {
    SnsParamsRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
    }
}

fn proposal_request(input: &str) -> SnsProposalRequest {
    SnsProposalRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
        proposal_id: 42,
        verbose: false,
    }
}

fn proposals_request(input: &str) -> SnsProposalsRequest {
    SnsProposalsRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
        limit: 10,
        before_proposal_id: Some(99),
        status: SnsProposalStatusFilter::Open,
        verbose: false,
    }
}

fn neurons_request(input: &str) -> SnsNeuronsRequest {
    SnsNeuronsRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: input.to_string(),
        limit: 10,
        owner_principal_id: None,
        sort: SnsNeuronsSort::Api,
        icp_root: None,
        verbose: false,
    }
}

fn sns_neurons_refresh_request(
    root: &std::path::Path,
    max_pages: Option<u32>,
) -> SnsNeuronsRefreshRequest {
    SnsNeuronsRefreshRequest {
        network: MAINNET_NETWORK.to_string(),
        source_endpoint: DEFAULT_SNS_SOURCE_ENDPOINT.to_string(),
        now_unix_secs: 1_780_531_200,
        input: "1".to_string(),
        icp_root: root.to_path_buf(),
        page_size: 2,
        max_pages,
    }
}

struct FixtureSnsParamsSource;

impl SnsListSource for FixtureSnsParamsSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsParamsSource for FixtureSnsParamsSource {
    fn fetch_sns_params(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
    ) -> Result<SnsGovernanceParameters, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        Ok(SnsGovernanceParameters {
            max_dissolve_delay_seconds: Some(252_460_800),
            max_dissolve_delay_bonus_percentage: Some(100),
            max_followees_per_function: Some(15),
            neuron_claimer_permissions: Some(SnsNeuronPermissionList {
                permissions: vec![1, 2, 3],
            }),
            neuron_minimum_stake_e8s: Some(100_000_000),
            max_neuron_age_for_age_bonus: Some(126_144_000),
            initial_voting_period_seconds: Some(345_600),
            neuron_minimum_dissolve_delay_to_vote_seconds: Some(2_592_000),
            reject_cost_e8s: Some(100_000_000),
            max_proposals_to_keep_per_action: Some(100),
            wait_for_quiet_deadline_increase_seconds: Some(86_400),
            max_number_of_neurons: Some(200_000),
            transaction_fee_e8s: Some(10_000),
            max_number_of_proposals_with_ballots: Some(700),
            max_age_bonus_percentage: Some(25),
            neuron_grantable_permissions: Some(SnsNeuronPermissionList {
                permissions: vec![4, 5],
            }),
            voting_rewards_parameters: Some(SnsVotingRewardsParameters {
                final_reward_rate_basis_points: Some(500),
                initial_reward_rate_basis_points: Some(1000),
                reward_rate_transition_duration_seconds: Some(189_216_000),
                round_duration_seconds: Some(86_400),
            }),
            maturity_modulation_disabled: Some(false),
            max_number_of_principals_per_neuron: Some(10),
            automatically_advance_target_version: Some(true),
            custom_proposal_criticality: Some(SnsCustomProposalCriticality {
                additional_critical_native_action_ids: vec![1, 2],
            }),
        })
    }
}

struct FixtureSnsProposalSource;

impl SnsListSource for FixtureSnsProposalSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsProposalSource for FixtureSnsProposalSource {
    fn fetch_sns_proposal(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
        proposal_id: u64,
    ) -> Result<MainnetSnsProposal, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        assert_eq!(proposal_id, 42);
        Ok(MainnetSnsProposal {
            proposal: fixture_proposal_row(),
        })
    }
}

struct FixtureSnsProposalsSource;

impl SnsListSource for FixtureSnsProposalsSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsProposalsSource for FixtureSnsProposalsSource {
    fn fetch_sns_proposals(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
        limit: u32,
        before_proposal_id: Option<u64>,
        include_status: &[i32],
    ) -> Result<MainnetSnsProposals, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        assert_eq!(limit, 10);
        assert_eq!(before_proposal_id, Some(99));
        assert_eq!(include_status, &[1]);
        Ok(MainnetSnsProposals {
            proposals: vec![fixture_proposal_row()],
        })
    }
}

struct FixtureSnsListSource;

impl SnsListSource for FixtureSnsListSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        Ok(MainnetSnsList {
            network: MAINNET_NETWORK.to_string(),
            sns_wasm_canister_id: MAINNET_SNS_WASM_CANISTER_ID.to_string(),
            fetched_at: request.fetched_at.clone(),
            fetched_by: request.fetched_by.clone(),
            source_endpoint: request.endpoint.clone(),
            sns_instances: vec![fixture_sns(
                "Fixture SNS",
                Some("Fixture description"),
                Some("https://example.com"),
                ROOT_A,
                GOVERNANCE_A,
                LEDGER_A,
                SWAP_A,
                INDEX_A,
                None,
            )],
        })
    }
}

struct UnsortedFixtureSnsListSource;

impl SnsListSource for UnsortedFixtureSnsListSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        Ok(MainnetSnsList {
            network: MAINNET_NETWORK.to_string(),
            sns_wasm_canister_id: MAINNET_SNS_WASM_CANISTER_ID.to_string(),
            fetched_at: request.fetched_at.clone(),
            fetched_by: request.fetched_by.clone(),
            source_endpoint: request.endpoint.clone(),
            sns_instances: vec![
                fixture_sns(
                    "A Name",
                    None,
                    None,
                    ROOT_A,
                    GOVERNANCE_A,
                    LEDGER_A,
                    SWAP_A,
                    INDEX_A,
                    None,
                ),
                fixture_sns(
                    "Z Name",
                    None,
                    None,
                    ROOT_B,
                    GOVERNANCE_B,
                    LEDGER_B,
                    SWAP_B,
                    INDEX_B,
                    None,
                ),
            ],
        })
    }
}

struct MetadataErrorFixtureSnsListSource;

impl SnsListSource for MetadataErrorFixtureSnsListSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        Ok(MainnetSnsList {
            network: MAINNET_NETWORK.to_string(),
            sns_wasm_canister_id: MAINNET_SNS_WASM_CANISTER_ID.to_string(),
            fetched_at: request.fetched_at.clone(),
            fetched_by: request.fetched_by.clone(),
            source_endpoint: request.endpoint.clone(),
            sns_instances: vec![fixture_sns(
                "unnamed-be2us",
                None,
                None,
                ROOT_A,
                GOVERNANCE_A,
                LEDGER_A,
                SWAP_A,
                INDEX_A,
                Some("get_metadata: Canister has no Wasm module"),
            )],
        })
    }
}

struct FixtureSnsTokenSource;

impl SnsListSource for FixtureSnsTokenSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsTokenSource for FixtureSnsTokenSource {
    fn fetch_sns_token(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
    ) -> Result<MainnetSnsToken, SnsHostError> {
        assert_eq!(sns.ledger_canister_id, LEDGER_A);
        Ok(MainnetSnsToken {
            token_name: "Fixture Token".to_string(),
            token_symbol: "FIX".to_string(),
            decimals: 8,
            transfer_fee: "10_000".to_string(),
            total_supply: "1_000_000_000".to_string(),
            minting_account_owner: Some(GOVERNANCE_A.to_string()),
            minting_account_subaccount_hex: Some("000102".to_string()),
            ledger_index_canister_id: Some(INDEX_A.to_string()),
            ledger_index_error: None,
            supported_standards: vec![
                SnsTokenStandardRow {
                    name: "ICRC-1".to_string(),
                    url: "https://github.com/dfinity/ICRC-1".to_string(),
                },
                SnsTokenStandardRow {
                    name: "ICRC-2".to_string(),
                    url: "https://github.com/dfinity/ICRC-2".to_string(),
                },
            ],
            metadata: vec![
                SnsTokenMetadataRow {
                    key: "icrc1:name".to_string(),
                    value_type: "text".to_string(),
                    value: serde_json::json!("Fixture Token"),
                },
                SnsTokenMetadataRow {
                    key: "icrc1:decimals".to_string(),
                    value_type: "nat".to_string(),
                    value: serde_json::json!("8"),
                },
                SnsTokenMetadataRow {
                    key: "icrc1:fee".to_string(),
                    value_type: "nat".to_string(),
                    value: serde_json::json!("10_000"),
                },
                SnsTokenMetadataRow {
                    key: "icrc1:logo".to_string(),
                    value_type: "bool".to_string(),
                    value: serde_json::json!(true),
                },
            ],
        })
    }
}

struct FixtureSnsNeuronsSource;

impl SnsListSource for FixtureSnsNeuronsSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsNeuronsSource for FixtureSnsNeuronsSource {
    fn fetch_sns_neurons(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
        limit: u32,
        owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeurons, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        assert_eq!(limit, 10);
        assert_eq!(owner_principal_id, Some(GOVERNANCE_A));
        Ok(MainnetSnsNeurons {
            neurons: vec![SnsNeuronRow {
                neuron_id: "0001020304".to_string(),
                cached_neuron_stake_e8s: 123,
                maturity_e8s_equivalent: 456,
                staked_maturity_e8s_equivalent: Some(789),
                created_timestamp_seconds: 1_780_272_000,
                created_at: "2026-06-01T00:00:00Z".to_string(),
            }],
        })
    }

    fn fetch_sns_neuron_page(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
        limit: u32,
        start_page_at: Option<&SnsNeuronId>,
        owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeuronPage, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        assert_eq!(limit, 10);
        assert!(start_page_at.is_none());
        assert_eq!(owner_principal_id, None);
        Ok(MainnetSnsNeuronPage {
            neurons: vec![SnsNeuronRow {
                neuron_id: "0001020304".to_string(),
                cached_neuron_stake_e8s: 123,
                maturity_e8s_equivalent: 456,
                staked_maturity_e8s_equivalent: Some(789),
                created_timestamp_seconds: 1_780_272_000,
                created_at: "2026-06-01T00:00:00Z".to_string(),
            }],
            last_cursor: Some(SnsNeuronId {
                id: vec![0, 1, 2, 3],
            }),
        })
    }
}

struct PagedFixtureSnsNeuronsSource;

impl SnsListSource for PagedFixtureSnsNeuronsSource {
    fn fetch_deployed_snses(
        &self,
        request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        FixtureSnsListSource.fetch_deployed_snses(request)
    }
}

impl SnsNeuronsSource for PagedFixtureSnsNeuronsSource {
    fn fetch_sns_neurons(
        &self,
        _request: &SnsFetchRequest,
        _sns: &MainnetSns,
        _limit: u32,
        _owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeurons, SnsHostError> {
        unreachable!("paged fixture is only used by complete cache refresh tests")
    }

    fn fetch_sns_neuron_page(
        &self,
        _request: &SnsFetchRequest,
        sns: &MainnetSns,
        limit: u32,
        start_page_at: Option<&SnsNeuronId>,
        owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeuronPage, SnsHostError> {
        assert_eq!(sns.governance_canister_id, GOVERNANCE_A);
        assert_eq!(limit, 2);
        assert_eq!(owner_principal_id, None);
        let cursor = start_page_at.map(|cursor| cursor.id.as_slice());
        let (neurons, last_cursor) = match cursor {
            None => (
                vec![neuron_row("01", 10), neuron_row("02", 30)],
                Some(vec![2]),
            ),
            Some([2]) => (
                vec![neuron_row("02", 30), neuron_row("03", 50)],
                Some(vec![3]),
            ),
            Some([3]) => (vec![neuron_row("03", 50)], Some(vec![3])),
            Some(other) => panic!("unexpected cursor {other:?}"),
        };
        Ok(MainnetSnsNeuronPage {
            neurons,
            last_cursor: last_cursor.map(|id| SnsNeuronId { id }),
        })
    }
}

struct NoLiveSnsNeuronsSource;

impl SnsListSource for NoLiveSnsNeuronsSource {
    fn fetch_deployed_snses(
        &self,
        _request: &SnsFetchRequest,
    ) -> Result<MainnetSnsList, SnsHostError> {
        unreachable!("cache-backed neuron report should not fetch deployed SNS list")
    }
}

impl SnsNeuronsSource for NoLiveSnsNeuronsSource {
    fn fetch_sns_neurons(
        &self,
        _request: &SnsFetchRequest,
        _sns: &MainnetSns,
        _limit: u32,
        _owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeurons, SnsHostError> {
        unreachable!("cache-backed neuron report should not fetch live neurons")
    }

    fn fetch_sns_neuron_page(
        &self,
        _request: &SnsFetchRequest,
        _sns: &MainnetSns,
        _limit: u32,
        _start_page_at: Option<&SnsNeuronId>,
        _owner_principal_id: Option<&str>,
    ) -> Result<MainnetSnsNeuronPage, SnsHostError> {
        unreachable!("cache-backed neuron report should not fetch neuron pages")
    }
}

fn neuron_row(neuron_id: &str, stake: u64) -> SnsNeuronRow {
    SnsNeuronRow {
        neuron_id: neuron_id.to_string(),
        cached_neuron_stake_e8s: stake,
        maturity_e8s_equivalent: stake / 2,
        staked_maturity_e8s_equivalent: None,
        created_timestamp_seconds: 1_780_272_000 + stake,
        created_at: format_utc_timestamp_secs(1_780_272_000 + stake),
    }
}

fn fixture_proposal_row() -> SnsProposalRow {
    SnsProposalRow {
        proposal_id: Some(42),
        action_id: 1,
        action: "motion".to_string(),
        title: "Fixture proposal".to_string(),
        summary: "Fixture proposal summary".to_string(),
        url: Some("https://example.com/proposal".to_string()),
        decision_state: "open".to_string(),
        reject_cost_e8s: 100_000_000,
        proposal_creation_timestamp_seconds: 1_780_272_000,
        created_at: "2026-06-01T00:00:00Z".to_string(),
        decided_timestamp_seconds: None,
        decided_at: None,
        executed_timestamp_seconds: None,
        executed_at: None,
        failed_timestamp_seconds: None,
        failed_at: None,
        failure_reason: None,
        reward_event_round: 0,
        reward_event_end_timestamp_seconds: None,
        is_eligible_for_rewards: true,
        latest_tally: Some(SnsProposalTally {
            timestamp_seconds: 1_780_272_100,
            yes: 10,
            no: 2,
            total: 20,
        }),
        ballot_count: 1,
        ballots: vec![SnsProposalBallotRow {
            neuron_id: "000102".to_string(),
            vote: 1,
            vote_text: "yes".to_string(),
            cast_timestamp_seconds: 1_780_272_050,
            cast_at: Some("2026-06-01T00:00:50Z".to_string()),
            voting_power: 10,
        }],
        payload_text_rendering: Some("Rendered payload".to_string()),
        proposer_neuron_id: Some("000102".to_string()),
    }
}

#[allow(clippy::too_many_arguments)]
fn fixture_sns(
    name: &str,
    description: Option<&str>,
    url: Option<&str>,
    root_canister_id: &str,
    governance_canister_id: &str,
    ledger_canister_id: &str,
    swap_canister_id: &str,
    index_canister_id: &str,
    metadata_error: Option<&str>,
) -> MainnetSns {
    MainnetSns {
        id: 0,
        name: name.to_string(),
        description: description.map(str::to_string),
        url: url.map(str::to_string),
        root_canister_id: root_canister_id.to_string(),
        governance_canister_id: governance_canister_id.to_string(),
        ledger_canister_id: ledger_canister_id.to_string(),
        swap_canister_id: swap_canister_id.to_string(),
        index_canister_id: index_canister_id.to_string(),
        metadata_error: metadata_error.map(str::to_string),
    }
}
