use crate::{
    ScriptArgs, ScriptConfig,
    broadcast::{BundledState, remaining_unsigned_transactions},
    execute::LinkedState,
    multi_sequence::MultiChainSequence,
    sequence::ScriptSequenceKind,
    session::{
        RemainingScriptTransaction, SignerScope, script_session_expected_sender_if_configured,
    },
};
use alloy_network::AnyNetwork;
use alloy_primitives::{Address, B256, map::AddressHashSet};
use alloy_provider::Provider;
use eyre::{OptionExt, Result};
use forge_script_sequence::ScriptSequence;
use foundry_cheatcodes::Wallets;
use foundry_cli::opts::TempoOpts;
use foundry_common::{
    ContractData, ContractsByArtifact, compile::ProjectCompiler, provider::ProviderBuilder,
};
use foundry_compilers::{
    ArtifactId, ProjectCompileOutput,
    artifacts::{BytecodeObject, Libraries},
    compilers::{Language, multi::MultiCompilerLanguage},
    info::ContractInfo,
    utils::source_files_iter,
};
use foundry_evm::{core::evm::FoundryEvmNetwork, traces::debug::ContractSources};
use foundry_linking::Linker;
use foundry_wallets::{MultiWalletOpts, wallet_browser::signer::BrowserSigner};
use std::{path::PathBuf, str::FromStr, sync::Arc};

/// Returns whether every scoped signer needed for resume is already available.
///
/// `Wallets` only tracks signers collected from CLI options and script cheatcodes. A Tempo
/// session signer lives in the Accounts store instead, so resume needs to treat the session
/// root account as available only on the chain covered by the session.
fn has_available_script_signers(
    tempo: &TempoOpts,
    wallets: &MultiWalletOpts,
    script_wallets: &Wallets,
    expected_sender: Option<Address>,
    remaining: &[RemainingScriptTransaction],
) -> Result<bool> {
    let signers = script_wallets
        .signers()
        .map_err(|e| eyre::eyre!("Failed to get available signers: {}", e))?;
    if remaining.is_empty() {
        return Ok(true);
    }

    let session_scope = tempo
        .session_signer_for_multi_wallet_any_chain(wallets, expected_sender)?
        .map(|s| SignerScope::new(s.session.chain_id, s.access_key.account()));

    Ok(remaining.iter().all(|tx| signers.contains(&tx.from) || session_scope == Some(tx.scope())))
}

/// Container for the compiled contracts.
#[derive(Clone, Debug)]
pub struct BuildData {
    /// Root of the project.
    pub project_root: PathBuf,
    /// The compiler output.
    pub output: ProjectCompileOutput,
    /// ID of target contract artifact.
    pub target: ArtifactId,
}

impl BuildData {
    pub fn get_linker(&self) -> Linker<'_> {
        Linker::new(self.project_root.clone(), self.output.artifact_ids().collect())
    }

    /// Links contracts. Uses CREATE2 linking when possible, otherwise falls back to
    /// default linking with sender nonce and address.
    pub async fn link<FEN: FoundryEvmNetwork>(
        self,
        script_config: &ScriptConfig<FEN>,
    ) -> Result<LinkedBuildData> {
        let create2_deployer = script_config.evm_opts.create2_deployer;
        let can_use_create2 = script_config
            .evm_opts
            .can_use_create2_deployer_resolved(script_config.resolved_fork()?)
            .await?;

        let known_libraries = script_config.config.libraries_with_remappings()?;

        let maybe_create2_link_output = can_use_create2
            .then(|| {
                self.get_linker()
                    .link_with_create2_detailed(
                        known_libraries.clone(),
                        create2_deployer,
                        script_config.config.create2_library_salt,
                        [&self.target],
                    )
                    .ok()
            })
            .flatten();

        let (libraries, predeploy_libs) = if let Some(output) = maybe_create2_link_output {
            (
                output.output.libraries,
                ScriptPredeployLibraries::Create2 {
                    onchain: output.linked_libraries,
                    salt: script_config.config.create2_library_salt,
                    local: Vec::new(),
                },
            )
        } else {
            let output = self.get_linker().link_with_nonce_or_address_detailed(
                known_libraries,
                script_config.evm_opts.sender,
                script_config.sender_nonce,
                [&self.target],
            )?;

            (
                output.output.libraries,
                ScriptPredeployLibraries::Default {
                    onchain: output.linked_libraries,
                    local: Vec::new(),
                },
            )
        };

        LinkedBuildData::new(libraries, predeploy_libs, self)
    }

    /// Links the build data with the given libraries. Expects supplied libraries set being enough
    /// to fully link target contract.
    pub fn link_with_libraries(self, libraries: Libraries) -> Result<LinkedBuildData> {
        LinkedBuildData::new(
            libraries,
            ScriptPredeployLibraries::Default { onchain: Vec::new(), local: Vec::new() },
            self,
        )
    }
}

#[derive(Clone, Debug)]
pub enum ScriptPredeployLibraries {
    Default {
        onchain: Vec<foundry_linking::LinkedLibrary>,
        local: Vec<foundry_linking::LinkedLibrary>,
    },
    Create2 {
        onchain: Vec<foundry_linking::LinkedLibrary>,
        salt: B256,
        local: Vec<foundry_linking::LinkedLibrary>,
    },
}

impl ScriptPredeployLibraries {
    pub const fn libraries_count(&self) -> usize {
        match self {
            Self::Default { onchain, .. } => onchain.len(),
            Self::Create2 { onchain, .. } => onchain.len(),
        }
    }
}

/// Container for the linked contracts and their dependencies
#[derive(Clone, Debug)]
pub struct LinkedBuildData {
    /// Original build data, might be used to relink this object with different libraries.
    pub build_data: BuildData,
    /// Known fully linked contracts.
    pub known_contracts: ContractsByArtifact,
    /// Libraries used to link the contracts.
    pub libraries: Libraries,
    /// Libraries that need to be deployed by sender before script execution.
    pub predeploy_libraries: ScriptPredeployLibraries,
    /// Source files of the contracts. Used by debugger.
    pub sources: ContractSources,
}

impl LinkedBuildData {
    pub fn new(
        libraries: Libraries,
        predeploy_libraries: ScriptPredeployLibraries,
        build_data: BuildData,
    ) -> Result<Self> {
        let sources = ContractSources::from_project_output(
            &build_data.output,
            &build_data.project_root,
            Some(&libraries),
        )?;

        let known_contracts =
            ContractsByArtifact::new(build_data.get_linker().get_linked_artifacts(&libraries)?);

        Ok(Self { build_data, known_contracts, libraries, predeploy_libraries, sources })
    }

    /// Fetches target bytecode from linked contracts.
    pub fn get_target_contract(&self) -> Result<&ContractData> {
        self.known_contracts
            .get(&self.build_data.target)
            .ok_or_eyre("target not found in linked artifacts")
    }
}

/// First state basically containing only inputs of the user.
pub struct PreprocessedState<FEN: FoundryEvmNetwork> {
    pub args: ScriptArgs,
    pub script_config: ScriptConfig<FEN>,
    pub script_wallets: Wallets,
    pub browser_wallet: Option<BrowserSigner<FEN::Network>>,
}

impl<FEN: FoundryEvmNetwork> PreprocessedState<FEN> {
    /// Parses user input and compiles the contracts depending on script target.
    /// After compilation, finds exact [ArtifactId] of the target contract.
    pub fn compile(self) -> Result<CompiledState<FEN>> {
        let Self { args, script_config, script_wallets, browser_wallet } = self;
        let project = script_config.config.project()?;

        let mut target_name = args.target_contract.clone();

        // If we've received correct path, use it as target_path
        // Otherwise, parse input as <path>:<name> and use the path from the contract info, if
        // present.
        let target_path = if let Ok(path) = dunce::canonicalize(&args.path) {
            path
        } else {
            let contract = ContractInfo::from_str(&args.path)?;
            target_name = Some(contract.name.clone());
            if let Some(path) = contract.path {
                dunce::canonicalize(path)?
            } else {
                project.find_contract_path(contract.name.as_str())?
            }
        };

        let sources_to_compile = source_files_iter(
            project.paths.sources.as_path(),
            MultiCompilerLanguage::FILE_EXTENSIONS,
        )
        .chain([target_path.clone()]);

        let output = ProjectCompiler::new()
            .files(sources_to_compile)
            .dynamic_test_linking(script_config.config.dynamic_test_linking)
            .compile(&project)?;

        let mut target_id: Option<ArtifactId> = None;

        // Find target artifact id by name and path in compilation artifacts.
        for (id, contract) in output.artifact_ids().filter(|(id, _)| id.source == target_path) {
            if let Some(name) = &target_name {
                if id.name != *name {
                    continue;
                }
            } else if contract.abi.as_ref().is_none_or(|abi| abi.is_empty())
                || contract.bytecode.as_ref().is_none_or(|b| match &b.object {
                    BytecodeObject::Bytecode(b) => b.is_empty(),
                    BytecodeObject::Unlinked(_) => false,
                })
            {
                // Ignore contracts with empty abi or linked bytecode of length 0 which are
                // interfaces/abstract contracts/libraries.
                continue;
            }

            if let Some(target) = target_id {
                // We might have multiple artifacts for the same contract but with different
                // solc versions. Their names will have form of {name}.0.X.Y, so we are
                // stripping versions off before comparing them.
                let target_name = target.name.split('.').next().unwrap();
                let id_name = id.name.split('.').next().unwrap();
                if target_name != id_name {
                    eyre::bail!(
                        "Multiple contracts in the target path. Please specify the contract name with `--tc ContractName`"
                    );
                }
            }
            target_id = Some(id);
        }

        let target = target_id.ok_or_eyre("Could not find target contract")?;

        Ok(CompiledState {
            args,
            script_config,
            script_wallets,
            browser_wallet,
            build_data: BuildData { output, target, project_root: project.root().to_path_buf() },
        })
    }
}

/// State after we have determined and compiled target contract to be executed.
pub struct CompiledState<FEN: FoundryEvmNetwork> {
    pub args: ScriptArgs,
    pub script_config: ScriptConfig<FEN>,
    pub script_wallets: Wallets,
    pub browser_wallet: Option<BrowserSigner<FEN::Network>>,
    pub build_data: BuildData,
}

impl<FEN: FoundryEvmNetwork> CompiledState<FEN> {
    /// Uses provided sender address to compute library addresses and link contracts with them.
    pub async fn link(self) -> Result<LinkedState<FEN>> {
        let Self { args, script_config, script_wallets, browser_wallet, build_data } = self;

        let build_data = build_data.link(&script_config).await?;

        Ok(LinkedState { args, script_config, script_wallets, browser_wallet, build_data })
    }

    /// Tries loading the resumed state from the cache files, skipping simulation stage.
    pub async fn resume(self) -> Result<BundledState<FEN>> {
        let chain = if self.args.multi {
            None
        } else {
            let fork_url = self.script_config.evm_opts.fork_url.clone().ok_or_eyre("Missing --fork-url field, if you were trying to broadcast a multi-chain sequence, please use --multi flag")?;
            let provider = Arc::new(ProviderBuilder::<AnyNetwork>::new(&fork_url).build()?);
            Some(provider.get_chain_id().await?)
        };

        let sequence = match self.try_load_sequence(chain, false) {
            Ok(sequence) => sequence,
            Err(_) => {
                // If the script was simulated, but there was no attempt to broadcast yet,
                // try to read the script sequence from the `dry-run/` folder
                let mut sequence = self.try_load_sequence(chain, true)?;

                // If sequence was in /dry-run, Update its paths so it is not saved into /dry-run
                // this time as we are about to broadcast it.
                sequence.update_paths_to_broadcasted(
                    &self.script_config.config,
                    &self.args.sig,
                    &self.build_data.target,
                )?;

                sequence.save(true, true)?;
                sequence
            }
        };

        let (args, build_data, script_wallets, browser_wallet, script_config) =
            if self.args.unlocked {
                (
                    self.args,
                    self.build_data,
                    self.script_wallets,
                    self.browser_wallet,
                    self.script_config,
                )
            } else {
                let remaining_transactions =
                    remaining_unsigned_transactions(sequence.sequences()).collect::<Vec<_>>();
                let remaining_froms =
                    remaining_transactions.iter().map(|tx| tx.from).collect::<AddressHashSet>();
                let expected_session_sender = script_session_expected_sender_if_configured(
                    &self.script_config.tempo,
                    &remaining_froms,
                )?;
                let has_available_signers = has_available_script_signers(
                    &self.script_config.tempo,
                    &self.args.wallets,
                    &self.script_wallets,
                    expected_session_sender,
                    &remaining_transactions,
                )?;

                if has_available_signers {
                    (
                        self.args,
                        self.build_data,
                        self.script_wallets,
                        self.browser_wallet,
                        self.script_config,
                    )
                } else {
                    // IF we are missing required signers, execute script as we might need to
                    // collect private keys from the execution.
                    let mut state = self;
                    state
                        .script_config
                        .update_tempo_session_sender(&state.args.wallets, state.args.evm.sender)
                        .await?;
                    let executed = state.link().await?.prepare_execution().await?.execute().await?;
                    (
                        executed.args,
                        executed.build_data.build_data,
                        executed.script_wallets,
                        executed.browser_wallet,
                        executed.script_config,
                    )
                }
            };

        // Collect libraries from sequence and link contracts with them.
        let libraries = match sequence {
            ScriptSequenceKind::Single(ref seq) => Libraries::parse(&seq.libraries)?,
            // Library linking is not supported for multi-chain sequences
            ScriptSequenceKind::Multi(_) => Libraries::default(),
        };

        let linked_build_data = build_data.link_with_libraries(libraries)?;

        Ok(BundledState {
            args,
            script_config,
            script_wallets,
            browser_wallet,
            build_data: linked_build_data,
            sequence,
        })
    }

    fn try_load_sequence(
        &self,
        chain: Option<u64>,
        dry_run: bool,
    ) -> Result<ScriptSequenceKind<FEN::Network>> {
        if let Some(chain) = chain {
            let sequence = ScriptSequence::load(
                &self.script_config.config,
                &self.args.sig,
                &self.build_data.target,
                chain,
                dry_run,
            )?;
            Ok(ScriptSequenceKind::Single(sequence))
        } else {
            let sequence = MultiChainSequence::load(
                &self.script_config.config,
                &self.args.sig,
                &self.build_data.target,
                dry_run,
            )?;
            Ok(ScriptSequenceKind::Multi(sequence))
        }
    }
}

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

    #[test]
    fn has_available_script_signers_skips_session_resolution_when_remaining_empty() {
        let has_available = has_available_script_signers(
            &TempoOpts { session: Some(B256::repeat_byte(0x99)), ..Default::default() },
            &MultiWalletOpts::default(),
            &Wallets::new(Default::default(), None),
            None,
            &[],
        )
        .unwrap();

        assert!(has_available);
    }
}
