use zeroize::Zeroizing;
use rand_core::{RngCore, OsRng};

use scale::Encode;

use serai_client::{
  primitives::{Amount, NetworkId, Coin, Balance, ExternalAddress},
  validator_sets::primitives::ExternalKey,
  in_instructions::primitives::{InInstruction, RefundableInInstruction, Shorthand},
};

use dockertest::{PullPolicy, Image, StartPolicy, TestBodySpecification, DockerOperations};

use crate::*;

pub const RPC_USER: &str = "serai";
pub const RPC_PASS: &str = "seraidex";

pub const BTC_PORT: u32 = 8332;
pub const ETH_PORT: u32 = 8545;
pub const XMR_PORT: u32 = 18081;

pub fn bitcoin_instance() -> (TestBodySpecification, u32) {
  serai_docker_tests::build("bitcoin".to_string());

  let composition = TestBodySpecification::with_image(
    Image::with_repository("serai-dev-bitcoin").pull_policy(PullPolicy::Never),
  )
  .set_publish_all_ports(true);
  (composition, BTC_PORT)
}

pub fn ethereum_instance() -> (TestBodySpecification, u32) {
  serai_docker_tests::build("ethereum".to_string());

  let composition = TestBodySpecification::with_image(
    Image::with_repository("serai-dev-ethereum").pull_policy(PullPolicy::Never),
  )
  .set_start_policy(StartPolicy::Strict)
  .set_publish_all_ports(true);
  (composition, ETH_PORT)
}

pub fn monero_instance() -> (TestBodySpecification, u32) {
  serai_docker_tests::build("monero".to_string());

  let composition = TestBodySpecification::with_image(
    Image::with_repository("serai-dev-monero").pull_policy(PullPolicy::Never),
  )
  .set_start_policy(StartPolicy::Strict)
  .set_publish_all_ports(true);
  (composition, XMR_PORT)
}

pub fn network_instance(network: NetworkId) -> (TestBodySpecification, u32) {
  match network {
    NetworkId::Bitcoin => bitcoin_instance(),
    NetworkId::Ethereum => ethereum_instance(),
    NetworkId::Monero => monero_instance(),
    NetworkId::Serai => {
      panic!("Serai is not a valid network to spawn an instance of for a processor")
    }
  }
}

pub fn network_rpc(network: NetworkId, ops: &DockerOperations, handle: &str) -> String {
  let (ip, port) = ops
    .handle(handle)
    .host_port(match network {
      NetworkId::Bitcoin => BTC_PORT,
      NetworkId::Ethereum => ETH_PORT,
      NetworkId::Monero => XMR_PORT,
      NetworkId::Serai => panic!("getting port for external network yet it was Serai"),
    })
    .unwrap();
  format!("http://{RPC_USER}:{RPC_PASS}@{ip}:{port}")
}

pub fn confirmations(network: NetworkId) -> usize {
  use processor::networks::*;
  match network {
    NetworkId::Bitcoin => Bitcoin::CONFIRMATIONS,
    NetworkId::Ethereum => Ethereum::<serai_db::MemDb>::CONFIRMATIONS,
    NetworkId::Monero => Monero::CONFIRMATIONS,
    NetworkId::Serai => panic!("getting confirmations required for Serai"),
  }
}

#[derive(Clone)]
pub enum Wallet {
  Bitcoin {
    private_key: bitcoin_serai::bitcoin::PrivateKey,
    public_key: bitcoin_serai::bitcoin::PublicKey,
    input_tx: bitcoin_serai::bitcoin::Transaction,
  },
  Ethereum {
    rpc_url: String,
    key: <ciphersuite::Secp256k1 as Ciphersuite>::F,
    nonce: u64,
  },
  Monero {
    handle: String,
    spend_key: Zeroizing<curve25519_dalek::scalar::Scalar>,
    view_pair: monero_wallet::ViewPair,
    last_tx: (usize, [u8; 32]),
  },
}

// TODO: Merge these functions with the processor's tests, which offers very similar functionality
impl Wallet {
  pub async fn new(network: NetworkId, ops: &DockerOperations, handle: String) -> Wallet {
    let rpc_url = network_rpc(network, ops, &handle);

    match network {
      NetworkId::Bitcoin => {
        use bitcoin_serai::{
          bitcoin::{
            secp256k1::{SECP256K1, SecretKey},
            PrivateKey, PublicKey, ScriptBuf, Network, Address,
          },
          rpc::Rpc,
        };

        let secret_key = SecretKey::new(&mut rand_core::OsRng);
        let private_key = PrivateKey::new(secret_key, Network::Regtest);
        let public_key = PublicKey::from_private_key(SECP256K1, &private_key);
        let main_addr = Address::p2pkh(public_key, Network::Regtest);

        let rpc = Rpc::new(rpc_url).await.expect("couldn't connect to the Bitcoin RPC");

        let new_block = rpc.get_latest_block_number().await.unwrap() + 1;
        rpc
          .rpc_call::<Vec<String>>("generatetoaddress", serde_json::json!([1, main_addr]))
          .await
          .unwrap();

        // Mine it to maturity
        rpc
          .rpc_call::<Vec<String>>(
            "generatetoaddress",
            serde_json::json!([100, Address::p2sh(&ScriptBuf::new(), Network::Regtest).unwrap()]),
          )
          .await
          .unwrap();

        let funds = rpc
          .get_block(&rpc.get_block_hash(new_block).await.unwrap())
          .await
          .unwrap()
          .txdata
          .swap_remove(0);

        Wallet::Bitcoin { private_key, public_key, input_tx: funds }
      }

      NetworkId::Ethereum => {
        use ciphersuite::{group::ff::Field, Secp256k1};
        use ethereum_serai::alloy::{
          primitives::{U256, Address},
          simple_request_transport::SimpleRequest,
          rpc_client::ClientBuilder,
          provider::{Provider, RootProvider},
          network::Ethereum,
        };

        let key = <Secp256k1 as Ciphersuite>::F::random(&mut OsRng);
        let address =
          ethereum_serai::crypto::address(&(<Secp256k1 as Ciphersuite>::generator() * key));

        let provider = RootProvider::<_, Ethereum>::new(
          ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true),
        );

        provider
          .raw_request::<_, ()>(
            "anvil_setBalance".into(),
            [Address(address.into()).to_string(), {
              let nine_decimals = U256::from(1_000_000_000u64);
              (U256::from(100u64) * nine_decimals * nine_decimals).to_string()
            }],
          )
          .await
          .unwrap();

        Wallet::Ethereum { rpc_url: rpc_url.clone(), key, nonce: 0 }
      }

      NetworkId::Monero => {
        use curve25519_dalek::{constants::ED25519_BASEPOINT_POINT, scalar::Scalar};
        use monero_simple_request_rpc::SimpleRequestRpc;
        use monero_wallet::{rpc::Rpc, address::Network, ViewPair};

        let spend_key = Scalar::random(&mut OsRng);
        let view_key = Scalar::random(&mut OsRng);

        let view_pair =
          ViewPair::new(ED25519_BASEPOINT_POINT * spend_key, Zeroizing::new(view_key)).unwrap();

        let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC");

        let height = rpc.get_height().await.unwrap();
        // Mines 200 blocks so sufficient decoys exist, as only 60 is needed for maturity
        rpc.generate_blocks(&view_pair.legacy_address(Network::Mainnet), 200).await.unwrap();
        let block = rpc.get_block(rpc.get_block_hash(height).await.unwrap()).await.unwrap();

        Wallet::Monero {
          handle,
          spend_key: Zeroizing::new(spend_key),
          view_pair,
          last_tx: (height, block.miner_transaction.hash()),
        }
      }
      NetworkId::Serai => panic!("creating a wallet for for Serai"),
    }
  }

  pub async fn send_to_address(
    &mut self,
    ops: &DockerOperations,
    to: &ExternalKey,
    instruction: Option<InInstruction>,
  ) -> (Vec<u8>, Balance) {
    match self {
      Wallet::Bitcoin { private_key, public_key, ref mut input_tx } => {
        use bitcoin_serai::bitcoin::{
          secp256k1::{SECP256K1, Message},
          key::{XOnlyPublicKey, TweakedPublicKey},
          consensus::Encodable,
          sighash::{EcdsaSighashType, SighashCache},
          script::{PushBytesBuf, Script, ScriptBuf, Builder},
          OutPoint, Sequence, Witness, TxIn, Amount, TxOut,
          absolute::LockTime,
          transaction::{Version, Transaction},
        };

        const AMOUNT: u64 = 100000000;
        let mut tx = Transaction {
          version: Version(2),
          lock_time: LockTime::ZERO,
          input: vec![TxIn {
            previous_output: OutPoint { txid: input_tx.compute_txid(), vout: 0 },
            script_sig: Script::new().into(),
            sequence: Sequence(u32::MAX),
            witness: Witness::default(),
          }],
          output: vec![
            TxOut {
              value: Amount::from_sat(input_tx.output[0].value.to_sat() - AMOUNT - 10000),
              script_pubkey: input_tx.output[0].script_pubkey.clone(),
            },
            TxOut {
              value: Amount::from_sat(AMOUNT),
              script_pubkey: ScriptBuf::new_p2tr_tweaked(
                TweakedPublicKey::dangerous_assume_tweaked(
                  XOnlyPublicKey::from_slice(&to[1 ..]).unwrap(),
                ),
              ),
            },
          ],
        };

        if let Some(instruction) = instruction {
          tx.output.push(TxOut {
            value: Amount::ZERO,
            script_pubkey: ScriptBuf::new_op_return(
              PushBytesBuf::try_from(
                Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode(),
              )
              .unwrap(),
            ),
          });
        }

        let mut der = SECP256K1
          .sign_ecdsa_low_r(
            &Message::from_digest_slice(
              SighashCache::new(&tx)
                .legacy_signature_hash(
                  0,
                  &input_tx.output[0].script_pubkey,
                  EcdsaSighashType::All.to_u32(),
                )
                .unwrap()
                .to_raw_hash()
                .as_ref(),
            )
            .unwrap(),
            &private_key.inner,
          )
          .serialize_der()
          .to_vec();
        der.push(1);
        tx.input[0].script_sig = Builder::new()
          .push_slice(PushBytesBuf::try_from(der).unwrap())
          .push_key(public_key)
          .into_script();

        let mut buf = vec![];
        tx.consensus_encode(&mut buf).unwrap();
        *input_tx = tx;
        (buf, Balance { coin: Coin::Bitcoin, amount: Amount(AMOUNT) })
      }

      Wallet::Ethereum { rpc_url, key, ref mut nonce } => {
        use std::sync::Arc;
        use ethereum_serai::{
          alloy::{
            primitives::{U256, TxKind},
            sol_types::SolCall,
            simple_request_transport::SimpleRequest,
            consensus::{TxLegacy, SignableTransaction},
            rpc_client::ClientBuilder,
            provider::{Provider, RootProvider},
            network::Ethereum,
          },
          crypto::PublicKey,
          deployer::Deployer,
        };

        let eight_decimals = U256::from(100_000_000u64);
        let nine_decimals = eight_decimals * U256::from(10u64);
        let eighteen_decimals = nine_decimals * nine_decimals;
        let one_eth = eighteen_decimals;

        let provider = Arc::new(RootProvider::<_, Ethereum>::new(
          ClientBuilder::default().transport(SimpleRequest::new(rpc_url.clone()), true),
        ));

        let to_as_key = PublicKey::new(
          <ciphersuite::Secp256k1 as Ciphersuite>::read_G(&mut to.as_slice()).unwrap(),
        )
        .unwrap();
        let router_addr = {
          // Find the deployer
          let deployer = Deployer::new(provider.clone()).await.unwrap().unwrap();

          // Find the router, deploying if non-existent
          let router = if let Some(router) =
            deployer.find_router(provider.clone(), &to_as_key).await.unwrap()
          {
            router
          } else {
            let mut tx = deployer.deploy_router(&to_as_key);
            tx.gas_price = 1_000_000_000u64.into();
            let tx = ethereum_serai::crypto::deterministically_sign(&tx);
            let signer = tx.recover_signer().unwrap();
            let (tx, sig, _) = tx.into_parts();

            provider
              .raw_request::<_, ()>(
                "anvil_setBalance".into(),
                [signer.to_string(), (tx.gas_limit * tx.gas_price).to_string()],
              )
              .await
              .unwrap();

            let mut bytes = vec![];
            tx.encode_with_signature_fields(&sig, &mut bytes);
            let _ = provider.send_raw_transaction(&bytes).await.unwrap();

            provider.raw_request::<_, ()>("anvil_mine".into(), [96]).await.unwrap();

            deployer.find_router(provider.clone(), &to_as_key).await.unwrap().unwrap()
          };

          router.address()
        };

        let tx = TxLegacy {
          chain_id: None,
          nonce: *nonce,
          gas_price: 1_000_000_000u128,
          gas_limit: 200_000u128,
          to: TxKind::Call(router_addr.into()),
          // 1 ETH
          value: one_eth,
          input: ethereum_serai::router::abi::inInstructionCall::new((
            [0; 20].into(),
            one_eth,
            if let Some(instruction) = instruction {
              Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode().into()
            } else {
              vec![].into()
            },
          ))
          .abi_encode()
          .into(),
        };

        *nonce += 1;

        let sig =
          k256::ecdsa::SigningKey::from(k256::elliptic_curve::NonZeroScalar::new(*key).unwrap())
            .sign_prehash_recoverable(tx.signature_hash().as_ref())
            .unwrap();

        let mut bytes = vec![];
        tx.encode_with_signature_fields(&sig.into(), &mut bytes);

        // We drop the bottom 10 decimals
        (
          bytes,
          Balance { coin: Coin::Ether, amount: Amount(u64::try_from(eight_decimals).unwrap()) },
        )
      }

      Wallet::Monero { handle, ref spend_key, ref view_pair, ref mut last_tx } => {
        use curve25519_dalek::constants::ED25519_BASEPOINT_POINT;
        use monero_simple_request_rpc::SimpleRequestRpc;
        use monero_wallet::{
          io::decompress_point,
          ringct::RctType,
          rpc::{FeePriority, Rpc},
          address::{Network, AddressType, Address},
          Scanner, OutputWithDecoys,
          send::{Change, SignableTransaction},
        };
        use processor::{additional_key, networks::Monero};

        let rpc_url = network_rpc(NetworkId::Monero, ops, handle);
        let rpc = SimpleRequestRpc::new(rpc_url).await.expect("couldn't connect to the Monero RPC");

        // Prepare inputs
        let current_height = rpc.get_height().await.unwrap();
        let mut outputs = vec![];
        for block in last_tx.0 .. current_height {
          let block = rpc.get_block_by_number(block).await.unwrap();
          if (block.miner_transaction.hash() == last_tx.1) ||
            block.transactions.contains(&last_tx.1)
          {
            outputs = Scanner::new(view_pair.clone())
              .scan(&rpc, &block)
              .await
              .unwrap()
              .ignore_additional_timelock();
          }
        }
        assert!(!outputs.is_empty());

        let mut inputs = Vec::with_capacity(outputs.len());
        for output in outputs {
          inputs.push(
            OutputWithDecoys::fingerprintable_deterministic_new(
              &mut OsRng,
              &rpc,
              16,
              rpc.get_height().await.unwrap(),
              output,
            )
            .await
            .unwrap(),
          );
        }

        let to_spend_key = decompress_point(<[u8; 32]>::try_from(to.as_slice()).unwrap()).unwrap();
        let to_view_key = additional_key::<Monero>(0);
        let to_addr = Address::new(
          Network::Mainnet,
          AddressType::Featured { subaddress: false, payment_id: None, guaranteed: true },
          to_spend_key,
          ED25519_BASEPOINT_POINT * to_view_key.0,
        );

        // Create and sign the TX
        const AMOUNT: u64 = 1_000_000_000_000;
        let mut data = vec![];
        if let Some(instruction) = instruction {
          data.push(Shorthand::Raw(RefundableInInstruction { origin: None, instruction }).encode());
        }
        let mut outgoing_view_key = Zeroizing::new([0; 32]);
        OsRng.fill_bytes(outgoing_view_key.as_mut());
        let tx = SignableTransaction::new(
          RctType::ClsagBulletproofPlus,
          outgoing_view_key,
          inputs,
          vec![(to_addr, AMOUNT)],
          Change::new(view_pair.clone(), None),
          data,
          rpc.get_fee_rate(FeePriority::Unimportant).await.unwrap(),
        )
        .unwrap()
        .sign(&mut OsRng, spend_key)
        .unwrap();

        // Update the last TX to track the change output
        last_tx.0 = current_height;
        last_tx.1 = tx.hash();

        (tx.serialize(), Balance { coin: Coin::Monero, amount: Amount(AMOUNT) })
      }
    }
  }

  pub fn address(&self) -> ExternalAddress {
    use serai_client::networks;

    match self {
      Wallet::Bitcoin { public_key, .. } => {
        use bitcoin_serai::bitcoin::ScriptBuf;
        ExternalAddress::new(
          networks::bitcoin::Address::new(ScriptBuf::new_p2pkh(&public_key.pubkey_hash()))
            .unwrap()
            .into(),
        )
        .unwrap()
      }
      Wallet::Ethereum { key, .. } => ExternalAddress::new(
        ethereum_serai::crypto::address(&(ciphersuite::Secp256k1::generator() * key)).into(),
      )
      .unwrap(),
      Wallet::Monero { view_pair, .. } => {
        use monero_wallet::address::Network;
        ExternalAddress::new(
          networks::monero::Address::new(view_pair.legacy_address(Network::Mainnet))
            .unwrap()
            .into(),
        )
        .unwrap()
      }
    }
  }
}
