import math
import random
from pathlib import Path

import algokit_utils
import algokit_utils.config
import algosdk
import pytest
from algokit_utils import LogicError
from algosdk import abi, constants, transaction
from algosdk.atomic_transaction_composer import AtomicTransactionComposer, TransactionWithSigner
from algosdk.transaction import OnComplete
from algosdk.v2client.algod import AlgodClient
from nacl.signing import SigningKey

from puya.arc32 import create_arc32_json
from puya.compilation_artifacts import CompiledContract
from puyapy.options import PuyaPyOptions
from tests import EXAMPLES_DIR, TEST_CASES_DIR
from tests.test_execution import decode_logs
from tests.utils import compile_src_from_options
from tests.utils.merkle_tree import MerkleTree, sha_256_raw

pytestmark = pytest.mark.localnet

def compile_arc32(
    src_path: Path,
    *,
    optimization_level: int = 1,
    debug_level: int = 2,
    file_name: str | None = None,
) -> str:
    result = compile_src_from_options(
        PuyaPyOptions(
            paths=(src_path,),
            optimization_level=optimization_level,
            debug_level=debug_level,
        )
    )
    if file_name is None:
        (contract,) = result.teal
    else:
        (contract,) = (
            t
            for t in result.teal
            if isinstance(t, CompiledContract)
            if (
                t.source_location
                and t.source_location.file
                and t.source_location.file.name == file_name
            )
        )

    assert isinstance(contract, CompiledContract), "Compilation artifact must be a contract"
    return create_arc32_json(
        contract.approval_program.teal_src, contract.clear_program.teal_src, contract.metadata
    )

def token_balances(
    app_client: algokit_utils.ApplicationClient,
    account: algokit_utils.Account,
    pool_token: int,
    asset_a: int,
    asset_b: int,
) -> dict[str, dict[str, int] | int]:
    account_info = app_client.algod_client.account_info(account.address)
    assert isinstance(account_info, dict)

    account_balances = {}
    for asset in account_info["assets"]:
        if asset["asset-id"] == pool_token:
            account_balances["pool"] = asset["amount"]
        if asset["asset-id"] == asset_a:
            account_balances["asset_a"] = asset["amount"]
        if asset["asset-id"] == asset_b:
            account_balances["asset_b"] = asset["amount"]

    app_account_info = app_client.algod_client.account_info(app_client.app_address)
    assert isinstance(app_account_info, dict)

    app_balances = {}
    for asset in app_account_info["assets"]:
        if asset["asset-id"] == pool_token:
            app_balances["pool"] = asset["amount"]
        if asset["asset-id"] == asset_a:
            app_balances["asset_a"] = asset["amount"]
        if asset["asset-id"] == asset_b:
            app_balances["asset_b"] = asset["amount"]

    state = app_client.get_global_state()
    ratio = state["ratio"]
    assert isinstance(ratio, int)
    return {"account": account_balances, "app": app_balances, "ratio": ratio}

def test_amm(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
    asset_a: int,
    asset_b: int,
) -> None:
    example = EXAMPLES_DIR / "amm"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    create_response = app_client.create()
    assert create_response.confirmed_round

    pay_txn = TransactionWithSigner(
        transaction.PaymentTxn(
            sender=account.address,
            receiver=app_client.app_address,
            amt=int(1e7),
            sp=algod_client.suggested_params(),
        ),
        signer=account.signer,
    )
    sp = algod_client.suggested_params()
    sp.flat_fee = True
    sp.fee = 4_000

    # bootstrap
    bootstrap_response = app_client.call(
        "bootstrap",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(suggested_params=sp),
        seed=pay_txn,
        a_asset=asset_a,
        b_asset=asset_b,
    )
    pool_token = bootstrap_response.return_value
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 10_000_000,
            "asset_b": 10_000_000,
        },
        "app": {
            "asset_a": 0,
            "asset_b": 0,
            "pool": 10_000_000_000,
        },
        "ratio": 0,
    }

    # pool_id should be newer than asset a + b
    assert pool_token > asset_a
    assert pool_token > asset_b

    # opt user into tokens
    # fund user account with assets a & b
    sp = algod_client.suggested_params()
    atc = AtomicTransactionComposer()
    atc.add_transaction(
        TransactionWithSigner(
            txn=transaction.AssetTransferTxn(account.address, sp, account.address, 0, asset_a),
            signer=account.signer,
        )
    )
    atc.add_transaction(
        TransactionWithSigner(
            txn=transaction.AssetTransferTxn(account.address, sp, account.address, 0, asset_b),
            signer=account.signer,
        )
    )
    atc.add_transaction(
        TransactionWithSigner(
            txn=transaction.AssetTransferTxn(account.address, sp, account.address, 0, pool_token),
            signer=account.signer,
        )
    )
    atc.execute(algod_client, wait_rounds=1)

    # mint
    sp = algod_client.suggested_params()
    sp.flat_fee = True
    sp.fee = 3_000

    app_client.call(
        "mint",
        a_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 10_000, asset_a
            ),
            signer=account.signer,
        ),
        b_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 3000, asset_b
            ),
            signer=account.signer,
        ),
        transaction_parameters=algokit_utils.OnCompleteCallParameters(suggested_params=sp),
    )
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 9_990_000,
            "asset_b": 9_997_000,
            "pool": 4_477,
        },
        "app": {
            "asset_a": 10_000,
            "asset_b": 3_000,
            "pool": 9_999_995_523,
        },
        "ratio": 3_333,
    }

    app_client.call(
        "mint",
        a_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 100_000, asset_a
            ),
            signer=account.signer,
        ),
        b_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 1_000, asset_b
            ),
            signer=account.signer,
        ),
        transaction_parameters=algokit_utils.OnCompleteCallParameters(suggested_params=sp),
    )
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 9_890_000,
            "asset_b": 9_996_000,
            "pool": 5_967,
        },
        "app": {
            "asset_a": 110_000,
            "asset_b": 4_000,
            "pool": 9_999_994_033,
        },
        "ratio": 27_500,
    }

    # swap
    app_client.call(
        "swap",
        swap_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 500, asset_a
            ),
            signer=account.signer,
        ),
    )
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 9_903_252,
            "asset_b": 9_996_000,
            "pool": 5_967,
        },
        "app": {
            "asset_a": 96_748,
            "asset_b": 4_000,
            "pool": 9_999_994_033,
        },
        "ratio": 24_187,
    }

    app_client.call(
        "swap",
        swap_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 500, asset_b
            ),
            signer=account.signer,
        ),
    )
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 9_903_252,
            "asset_b": 9_995_523,
            "pool": 5_967,
        },
        "app": {
            "asset_a": 96_748,
            "asset_b": 4_477,
            "pool": 9_999_994_033,
        },
        "ratio": 21_610,
    }

    # burn
    app_client.call(
        "burn",
        pool_xfer=TransactionWithSigner(
            txn=transaction.AssetTransferTxn(
                account.address, sp, app_client.app_address, 100, pool_token
            ),
            signer=account.signer,
        ),
    )
    assert token_balances(app_client, account, pool_token, asset_a, asset_b) == {
        "account": {
            "asset_a": 9_904_929,
            "asset_b": 9_995_600,
            "pool": 5_867,
        },
        "app": {
            "asset_a": 95_071,
            "asset_b": 4_400,
            "pool": 9_999_994_133,
        },
        "ratio": 21_607,
    }

@pytest.fixture
def voter_account(algod_client: AlgodClient) -> algokit_utils.Account:
    v = algosdk.account.generate_account()
    voter_account = algokit_utils.Account(private_key=v[0], address=v[1])
    algokit_utils.transfer(
        client=algod_client,
        parameters=algokit_utils.TransferParameters(
            from_account=algokit_utils.get_localnet_default_account(algod_client),
            to_address=voter_account.address,
            micro_algos=10000000,
        ),
    )
    return voter_account

def suggested_params(
    *, algod_client: AlgodClient, fee: int | None = None, flat_fee: bool | None = None
) -> algosdk.transaction.SuggestedParams:
    sp = algod_client.suggested_params()

    if fee is not None:
        sp.fee = fee
    if flat_fee is not None:
        sp.flat_fee = flat_fee

    return sp

def payment_transaction(
    *,
    algod_client: AlgodClient,
    amount: int,
    receiver: str,
    sender: algokit_utils.Account,
    note: bytes | None = None,
) -> TransactionWithSigner:
    return TransactionWithSigner(
        txn=algosdk.transaction.PaymentTxn(
            sender=sender.address,
            receiver=receiver,
            amt=amount,
            note=note,
            sp=suggested_params(algod_client=algod_client),
        ),
        signer=sender.signer,
    )

def test_voting_app(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
    voter_account: algokit_utils.Account,
) -> None:
    creator_account = account

    private_key = SigningKey.generate()

    example = EXAMPLES_DIR / "voting"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=creator_account)

    quorum = math.ceil(random.randint(1, 9) * 1000)
    question_counts = [1] * 10

    health = algod_client.status()
    assert isinstance(health, dict)
    response = algod_client.block_info(health["last-round"])
    assert isinstance(response, dict)
    block = response["block"]
    block_ts = block["ts"]
    app_client.create(
        "create",
        vote_id="1",
        metadata_ipfs_cid="cid",
        start_time=int(block_ts),
        end_time=int(block_ts) + 1000,
        quorum=quorum,
        snapshot_public_key=private_key.verify_key.encode(),
        nft_image_url="ipfs://cid",
        option_counts=question_counts,
    )

    app_client.call(
        call_abi_method="bootstrap",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(boxes=[(0, "V")]),
        fund_min_bal_req=payment_transaction(
            algod_client=algod_client,
            amount=(100000 * 2) + 1000 + 2500 + 400 * (1 + 8 * 10),
            sender=creator_account,
            note=b"Bootstrap payment",
            receiver=app_client.app_address,
        ),
    )

    def get_account_signature(voter_public_key: bytes) -> bytes:
        signed = private_key.sign(voter_public_key)
        return signed.signature

    pre_conditions = app_client.call(
        call_abi_method="get_preconditions",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            sender=voter_account.address,
            signer=voter_account.signer,
            boxes=[(0, voter_account.public_key)],
            suggested_params=suggested_params(algod_client=algod_client, fee=4000),
        ),
        signature=get_account_signature(voter_account.public_key),
    )
    (is_open, can_vote, has_voted, _time) = pre_conditions.return_value
    assert is_open
    assert can_vote
    assert not has_voted

    app_client.call(
        call_abi_method="vote",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[(0, "V"), (0, voter_account.public_key)],
            sender=voter_account.address,
            signer=voter_account.signer,
            suggested_params=suggested_params(algod_client=algod_client, fee=12000, flat_fee=True),
        ),
        answer_ids=[0] * 10,
        fund_min_bal_req=payment_transaction(
            algod_client=algod_client,
            amount=400 * (32 + 2 + 10) + 2500,
            sender=voter_account,
            note=b"Vote payment",
            receiver=app_client.app_address,
        ),
        signature=get_account_signature(voter_account.public_key),
    )

    pre_conditions = app_client.call(
        call_abi_method="get_preconditions",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            sender=voter_account.address,
            signer=voter_account.signer,
            boxes=[(0, voter_account.public_key)],
            suggested_params=suggested_params(algod_client=algod_client, fee=4000),
        ),
        signature=get_account_signature(voter_account.public_key),
    )
    (is_open, can_vote, has_voted, _time) = pre_conditions.return_value
    assert is_open
    assert can_vote
    assert has_voted

    app_client.call(
        call_abi_method="close",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[(0, "V")],
            sender=creator_account.address,
            signer=creator_account.signer,
            suggested_params=suggested_params(
                algod_client=algod_client, fee=1000000, flat_fee=True
            ),
        ),
    )

    pre_conditions = app_client.call(
        call_abi_method="get_preconditions",
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[(0, account.public_key)],
            suggested_params=suggested_params(algod_client=algod_client, fee=4000),
        ),
        signature=get_account_signature(voter_account.public_key),
    )
    (is_open, _can_vote, _has_voted, _time) = pre_conditions.return_value
    assert not is_open

def test_arc4_routing(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "abi_routing" / "contract.py")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    create_response = app_client.create()
    assert create_response.confirmed_round

    app_client.call("method_with_default_args")

    hello_result = app_client.call("hello_with_algopy_string", name="Algopy")
    assert hello_result.return_value == "Hello Algopy!"

def test_arc4_routing_with_many_params(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
    asset_a: int,
    asset_b: int,
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "abi_routing" / "contract.py")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    create_response = app_client.create()
    assert create_response.confirmed_round

    result = app_client.call(
        "method_with_more_than_15_args",
        a=1,
        b=1,
        c=1,
        d=1,
        e=1,
        f=1,
        g=1,
        h=1,
        i=1,
        j=1,
        k=1,
        l=1,
        m=1,
        n=1,
        o=1,
        p=1,
        q=1,
        r=1,
        s=b"a",
        t=b"b",
        u=1,
        v=1,
        pay=payment_transaction(
            algod_client=algod_client,
            amount=100000,
            sender=account,
            note=b"Test 1",
            receiver=app_client.app_address,
        ),
        pay2=payment_transaction(
            algod_client=algod_client,
            amount=200000,
            sender=account,
            note=b"Test 2",
            receiver=app_client.app_address,
        ),
        asset=asset_a,
        asset2=asset_b,
    )
    assert result.return_value == 20

    (logged_string,) = decode_logs(result.tx_info["logs"][:-1], "u")
    assert logged_string == "ab"

    result2 = app_client.call(
        "method_with_15_args",
        one=1,
        two=1,
        three=1,
        four=1,
        five=1,
        six=1,
        seven=1,
        eight=1,
        nine=1,
        ten=1,
        eleven=1,
        twelve=1,
        thirteen=1,
        fourteen=1,
        fifteen=b"fifteen",
    )
    assert result2.return_value == list(b"fifteen")

def test_transaction(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "transaction")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    create_response = app_client.create()
    assert create_response.confirmed_round

    # ensure app meets minimum balance requirements
    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=100_000,
        ),
    )

    app_client.call(
        "pay",
        txn=TransactionWithSigner(
            transaction.PaymentTxn(
                sender=account.address,
                receiver=app_client.app_address,
                amt=1001,
                sp=algod_client.suggested_params(),
            ),
            signer=account.signer,
        ),
    )

    # TODO: call remaining transaction methods

def test_dynamic_array_of_string(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "arc4_types/dynamic_string_array.py")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    create_response = app_client.create()
    assert create_response.confirmed_round

    xyz_result = app_client.call("xyz")
    assert xyz_result.return_value == list("XYZ")

    xyz_raw_result = app_client.call("xyz_raw")
    assert xyz_raw_result.return_value == list("XYZ")

def test_array_rebinding(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "arc4_types/mutable_params2.py")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    create_response = app_client.create()
    assert create_response.confirmed_round

    app_client.call("test_array_rebinding")

def test_avm_types_in_abi(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    example = TEST_CASES_DIR / "avm_types_in_abi" / "contract.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)
    max_biguint = 2**512 - 1
    result = app_client.create(
        "create",
        bool_param=True,
        uint64_param=45,
        bytes_param=b"Hello world!",
        biguint_param=max_biguint,
        string_param="Hi again",
        tuple_param=(True, 45, b"Hello world!", max_biguint, "Hi again"),
    )

    mapped_return = (
        result.return_value[0],
        result.return_value[1],
        bytes(result.return_value[2]),
        result.return_value[3],
        result.return_value[4],
    )

    assert mapped_return == (True, 45, b"Hello world!", max_biguint, "Hi again")

    result2 = app_client.call(call_abi_method="tuple_of_arc4", args=(255, account.address))

    assert result2.return_value[0] == 255
    assert result2.return_value[1] == account.address

def test_inner_transactions_c2c(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    example = TEST_CASES_DIR / "inner_transactions" / "c2c.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    # deploy greeter
    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 2
    app_client = algokit_utils.ApplicationClient(
        algod_client, app_spec, signer=account, suggested_params=increased_fee
    )
    app_client.create()

    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=100_000,
        ),
    )
    inner_app_id = app_client.call("bootstrap").return_value
    assert isinstance(inner_app_id, int)

    result = app_client.call(
        "log_greetings",
        name="There",
        transaction_parameters={"foreign_apps": [inner_app_id]},
    )
    assert decode_logs(result.tx_info["logs"], "b") == [b"HelloWorld returned: Hello, There"]

def test_inner_transactions_array_access(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> None:
    example = TEST_CASES_DIR / "inner_transactions" / "array_access.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    # deploy greeter
    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 2
    app_client = algokit_utils.ApplicationClient(
        algod_client, app_spec, signer=account, suggested_params=increased_fee
    )
    app_client.create()

    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=200_000,
        ),
    )

    app_client.call("test_branching_array_call", maybe=True)
    app_client.call("test_branching_array_call", maybe=False)

def test_inner_transactions_tuple(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> None:
    example = TEST_CASES_DIR / "inner_transactions" / "field_tuple_assignment.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 7
    app_client = algokit_utils.ApplicationClient(
        algod_client, app_spec, signer=account, suggested_params=increased_fee
    )
    app_client.create()

    app_client.call("test_assign_tuple")
    app_client.call("test_assign_tuple_mixed")

def test_inner_transactions_asset_transfer(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> None:
    example = TEST_CASES_DIR / "inner_transactions" / "asset_transfer.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    # deploy
    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 3
    app_client = algokit_utils.ApplicationClient(
        algod_client, app_spec, signer=account, suggested_params=increased_fee
    )
    app_client.create()

    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=200_000,
        ),
    )

    app_client.call("create_and_transfer")

def test_inner_transactions_assignment(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> None:
    example = TEST_CASES_DIR / "inner_transactions_assignment"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 8
    app_client = algokit_utils.ApplicationClient(
        algod_client, app_spec, signer=account, suggested_params=increased_fee
    )
    app_client.create()

    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=400_000,
        ),
    )

    app_client.call("test_itxn_slice")
    app_client.call("test_itxn_nested")

def test_state_proxies(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    example = TEST_CASES_DIR / "state_proxies" / "contract.py"

    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    app_client.create(transaction_parameters={"on_complete": OnComplete.OptInOC})
    assert app_client.get_global_state() == {"g1": 1, "g2": 0, "funky": 123}
    assert app_client.get_local_state(account.address) == {"l1": 2, "l2": 3}

def test_template_variables(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    example = TEST_CASES_DIR / "template_variables"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
        template_values={
            "SOME_BYTES": b"Hello I'm a variable",
            "SOME_BIG_UINT": (1337).to_bytes(length=2),
            "UPDATABLE": 1,
            "DELETABLE": 1,
        },
    )

    app_client.create()

    get_bytes = app_client.call(
        call_abi_method="get_bytes",
    )
    assert bytes(get_bytes.return_value) == b"Hello I'm a variable"

    get_uint = app_client.call(
        call_abi_method="get_big_uint",
    )

    assert get_uint.return_value == 1337

    app_client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
        app_id=app_client.app_id,
        template_values={
            "SOME_BYTES": b"Updated variable",
            "SOME_BIG_UINT": (0).to_bytes(length=2),
            "UPDATABLE": 0,
            "DELETABLE": 1,
        },
    )

    app_client.update()

    get_bytes = app_client.call(
        call_abi_method="get_bytes",
    )
    assert bytes(get_bytes.return_value) == b"Updated variable"

    get_uint = app_client.call(
        call_abi_method="get_big_uint",
    )

    assert get_uint.return_value == 0

    try:
        app_client.update()
        raise AssertionError("Update should fail")
    except LogicError:
        pass

    app_client.delete()

def test_merkle(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    example = EXAMPLES_DIR / "merkle"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
    )

    test_tree = MerkleTree(
        [
            b"a",
            b"b",
            b"c",
            b"d",
            b"e",
        ]
    )
    app_client.create(call_abi_method="create", root=test_tree.root)

    assert app_client.call(
        call_abi_method="verify", leaf=sha_256_raw(b"a"), proof=test_tree.get_proof(b"a")
    ).return_value

def test_typed_abi_call(
    algod_client: AlgodClient, account: algokit_utils.Account, asset_a: int
) -> None:
    logger = algokit_utils.ApplicationClient(
        algod_client,
        algokit_utils.ApplicationSpecification.from_json(
            compile_arc32(TEST_CASES_DIR / "typed_abi_call" / "logger.py")
        ),
        signer=account,
    )
    logger.create()

    example = TEST_CASES_DIR / "typed_abi_call" / "typed_c2c.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))

    # deploy greeter
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)
    app_client.create()

    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 6
    txn_params = algokit_utils.OnCompleteCallParameters(suggested_params=increased_fee)

    app_client.call(
        "test_method_selector_kinds",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_arg_conversion",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_method_overload",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_15plus_args",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_void",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_ref_types",
        transaction_parameters=txn_params,
        app=logger.app_id,
        asset=asset_a,
    )

    app_client.call(
        "test_native_tuple",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_native_tuple_method_ref",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_nested_tuples",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_native_string",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_native_bytes",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_native_uint64",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_native_biguint",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_no_args",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

    app_client.call(
        "test_is_a_b",
        transaction_parameters=txn_params,
        a=b"a",
        b=b"b",
        app=logger.app_id,
    )

    app_client.call(
        "test_named_tuples",
        transaction_parameters=txn_params,
        app=logger.app_id,
    )

def test_arc28(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    app_client = algokit_utils.ApplicationClient(
        algod_client,
        algokit_utils.ApplicationSpecification.from_json(compile_arc32(EXAMPLES_DIR / "arc_28")),
        signer=account,
    )
    app_client.create()

    a = 42
    b = 12
    result = app_client.call(
        "emit_swapped",
        a=a,
        b=b,
    )
    logs = result.tx_info["logs"]
    events = decode_logs(logs, "bbb")
    for event in events:
        assert isinstance(event, bytes)
        event_sig = event[:4]
        event_data = event[4:]
        assert event_sig == bytes.fromhex("1ccbd925")
        assert event_data == b.to_bytes(length=8) + a.to_bytes(length=8)

@pytest.fixture
def other_account(algod_client: AlgodClient) -> algokit_utils.Account:
    v = algosdk.account.generate_account()
    voter_account = algokit_utils.Account(private_key=v[0], address=v[1])
    algokit_utils.transfer(
        client=algod_client,
        parameters=algokit_utils.TransferParameters(
            from_account=algokit_utils.get_localnet_default_account(algod_client),
            to_address=voter_account.address,
            micro_algos=10000000,
        ),
    )
    return voter_account

def test_tictactoe(
    algod_client: AlgodClient, account: algokit_utils.Account, other_account: algokit_utils.Account
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(EXAMPLES_DIR / "tictactoe" / "tictactoe.py")
    )
    client_host = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
    )

    client_host.create(call_abi_method="new_game", move=(0, 0))
    turn_result = client_host.call("whose_turn")
    assert turn_result.return_value == 2
    # no one has joined, can start a new game
    client_host.call(call_abi_method="new_game", move=(1, 1))

    with pytest.raises(algokit_utils.logic_error.LogicError) as exc_info:
        client_host.call(call_abi_method="play", move=(0, 0))
    assert exc_info.value.line_no is not None
    assert "It is the challenger's turn" in exc_info.value.lines[exc_info.value.line_no]

    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "- - -",
        "- X -",
        "- - -",
    ]
    assert winner is None

    client_challenger = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        app_id=client_host.app_id,
        signer=other_account,
    )

    client_challenger.call(call_abi_method="join_game", move=(0, 0))

    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "O - -",
        "- X -",
        "- - -",
    ]
    assert winner is None

    turn_result = client_challenger.call("whose_turn")
    assert turn_result.return_value == 1

    with pytest.raises(algokit_utils.logic_error.LogicError) as exc_info:
        client_host.call(call_abi_method="new_game", move=(2, 2))
    assert exc_info.value.line_no is not None
    assert "Game isn't over" in exc_info.value.lines[exc_info.value.line_no]

    client_host.call(call_abi_method="play", move=(0, 1))

    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "O - -",
        "X X -",
        "- - -",
    ]
    assert winner is None

    client_challenger.call(call_abi_method="play", move=(1, 0))

    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "O O -",
        "X X -",
        "- - -",
    ]
    assert winner is None

    client_host.call(call_abi_method="play", move=(2, 1))

    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "O O -",
        "X X X",
        "- - -",
    ]
    assert winner == "Host"

    client_host.call(call_abi_method="new_game", move=(1, 1))
    game, winner = _get_tic_tac_toe_game_status(client_host)
    assert game == [
        "- - -",
        "- X -",
        "- - -",
    ]
    assert winner is None

def _get_tic_tac_toe_game_status(
    client_host: algokit_utils.ApplicationClient,
) -> tuple[list[str], str | None]:
    state = client_host.get_global_state(raw=True)
    game = state[b"game"]
    assert isinstance(game, bytes)
    chars = ["X" if b == 1 else "O" if b == 2 else "-" for b in game]
    board = [" ".join(chars[:3]), " ".join(chars[3:6]), " ".join(chars[6:])]

    winner_index = state.get(b"winner")
    assert isinstance(winner_index, bytes | None)
    winner = {
        None: None,
        b"\01": "Host",
        b"\02": "Challenger",
        b"\03": "Draw",
    }[winner_index]
    return board, winner

@pytest.fixture(scope="module")
def dynamic_app_client(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> algokit_utils.ApplicationClient:
    app_client = algokit_utils.ApplicationClient(
        algod_client,
        algokit_utils.ApplicationSpecification.from_json(
            compile_arc32(TEST_CASES_DIR / "arc4_dynamic_arrays")
        ),
        signer=account,
    )
    app_client.create()
    return app_client

def test_dynamic_arrays_static_element(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    static_struct_t = abi.ABIType.from_string("(uint64,byte[2])")
    static_arr_t = abi.ArrayDynamicType(static_struct_t)
    static_struct0 = (3, bytes((4, 5)))
    static_struct1 = (2**42, bytes((42, 255)))

    static_result = dynamic_app_client.call("test_static_elements")
    (static_arr_bytes, static_0_bytes, static_1_bytes) = decode_logs(
        static_result.tx_info["logs"], "bbb"
    )

    assert static_arr_bytes == static_arr_t.encode([static_struct0, static_struct1])
    assert static_0_bytes == static_struct_t.encode(static_struct0)
    assert static_1_bytes == static_struct_t.encode(static_struct1)

def test_dynamic_arrays_dynamic_element(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    dynamic_struct_t = abi.ABIType.from_string("(string,string)")
    dynamic_arr_t = abi.ABIType.from_string("(string,string)[]")
    dynamic_struct0 = ("a", "bee")
    dynamic_struct1 = ("Hello World", "a")

    dynamic_result = dynamic_app_client.call("test_dynamic_elements")
    (
        dynamic_arr_bytes,
        dynamic_0_bytes,
        dynamic_1_bytes,
        dynamic_2_bytes,
        dynamic_arr_bytes_01,
        dynamic_arr_bytes_0,
        empty_arr,
    ) = decode_logs(dynamic_result.tx_info["logs"], "b" * 7)

    assert dynamic_arr_bytes == dynamic_arr_t.encode(
        [dynamic_struct0, dynamic_struct1, dynamic_struct0]
    )
    assert dynamic_0_bytes == dynamic_struct_t.encode(dynamic_struct0)
    assert dynamic_1_bytes == dynamic_struct_t.encode(dynamic_struct1)
    assert dynamic_2_bytes == dynamic_struct_t.encode(dynamic_struct0)
    assert dynamic_arr_bytes_01 == dynamic_arr_t.encode([dynamic_struct0, dynamic_struct1])
    assert dynamic_arr_bytes_0 == dynamic_arr_t.encode([dynamic_struct0])
    assert empty_arr == dynamic_arr_t.encode([])

def test_dynamic_arrays_mixed_single_dynamic(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    mixed1_struct_t = abi.ABIType.from_string("(uint64,string,uint64)")
    mixed1_arr_t = abi.ArrayDynamicType(mixed1_struct_t)
    mixed1_struct0 = (3, "a", 2**42)
    mixed1_struct1 = (2**42, "bee", 3)

    mixed_single_result = dynamic_app_client.call("test_mixed_single_dynamic_elements")
    (mixed1_arr_bytes, mixed1_0_bytes, mixed1_1_bytes) = decode_logs(
        mixed_single_result.tx_info["logs"], "bbb"
    )

    assert mixed1_arr_bytes == mixed1_arr_t.encode([mixed1_struct0, mixed1_struct1])
    assert mixed1_0_bytes == mixed1_struct_t.encode(mixed1_struct0)
    assert mixed1_1_bytes == mixed1_struct_t.encode(mixed1_struct1)

def test_dynamic_arrays_mixed_multiple_dynamic(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    mixed2_struct_t = abi.ABIType.from_string("(uint64,string,uint64,uint16[],uint64)")
    mixed2_arr_t = abi.ArrayDynamicType(mixed2_struct_t)
    mixed2_struct0 = (3, "a", 2**42, (2**16 - 1, 0, 42), 3)
    mixed2_struct1 = (2**42, "bee", 3, (1, 2, 3, 4), 2**42)

    mixed_multiple_result = dynamic_app_client.call("test_mixed_multiple_dynamic_elements")
    (mixed2_arr_bytes, mixed2_0_bytes, mixed2_1_bytes) = decode_logs(
        mixed_multiple_result.tx_info["logs"], "bbb"
    )

    assert mixed2_arr_bytes == mixed2_arr_t.encode([mixed2_struct0, mixed2_struct1])
    assert mixed2_0_bytes == mixed2_struct_t.encode(mixed2_struct0)
    assert mixed2_1_bytes == mixed2_struct_t.encode(mixed2_struct1)

def test_nested_struct(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    dynamic_app_client.call("test_nested_struct_replacement")

def test_nested_tuple(
    dynamic_app_client: algokit_utils.ApplicationClient,
) -> None:
    dynamic_app_client.call("test_nested_tuple_modification")

def test_struct_in_box(
    algod_client: AlgodClient, account: algokit_utils.Account, asset_a: int
) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(EXAMPLES_DIR / "struct_in_box" / "contract.py")
    )
    client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
    )

    # Create the application
    client.create()

    # Fund the application (so it can have boxes)
    algokit_utils.transfer(
        algod_client,
        algokit_utils.TransferParameters(
            to_address=client.app_address, from_account=account, micro_algos=10_000_000
        ),
    )

    user_1 = {"id": 1, "name": "Bob", "asset": 0}
    user_2 = {"id": 2, "name": "Jane", "asset": 0}

    client.call(
        "add_user",
        user=user_1,
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[
                (0, (1).to_bytes(8)),
            ]
        ),
    )

    client.call(
        "add_user",
        user=user_2,
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[
                (0, (2).to_bytes(8)),
            ]
        ),
    )

    client.call(
        "attach_asset_to_user",
        user_id=user_1["id"],
        asset=asset_a,
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[
                (0, (1).to_bytes(8)),
            ]
        ),
    )

    user_1_result = client.call(
        "get_user",
        user_id=1,
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[
                (0, (1).to_bytes(8)),
            ]
        ),
    )
    assert user_1_result.return_value == ["Bob", 1, asset_a]
    user_2_result = client.call(
        "get_user",
        user_id=2,
        transaction_parameters=algokit_utils.OnCompleteCallParameters(
            boxes=[
                (0, (2).to_bytes(8)),
            ]
        ),
    )
    assert user_2_result.return_value == ["Jane", 2, 0]

_ADDITIONAL_BOX_REF = (0, b"")

@pytest.fixture
def box_client(
    algod_client: AlgodClient, account: algokit_utils.Account
) -> algokit_utils.ApplicationClient:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(EXAMPLES_DIR / "box_storage" / "contract.py")
    )
    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=account, min_spending_balance_micro_algos=20_000_000
        ),
    )
    client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
    )

    client.create()

    algokit_utils.transfer(
        algod_client,
        algokit_utils.TransferParameters(
            to_address=client.app_address, from_account=account, micro_algos=10_000_000
        ),
    )
    return client

def _params_with_boxes(
    *keys: str | bytes | int, additional_refs: int = 0
) -> algokit_utils.OnCompleteCallParameters:
    return algokit_utils.OnCompleteCallParameters(
        boxes=[(0, key.to_bytes(8) if isinstance(key, int) else key) for key in keys]
        + [_ADDITIONAL_BOX_REF] * additional_refs
    )

def test_box(box_client: algokit_utils.ApplicationClient) -> None:
    box_c = b"BOX_C"
    transaction_parameters = _params_with_boxes("box_a", "b", box_c, "box_d")

    (a_exist, b_exist, c_exist) = box_client.call(
        call_abi_method="boxes_exist",
        transaction_parameters=transaction_parameters,
    ).return_value
    assert not a_exist
    assert not b_exist
    assert not c_exist

    box_client.call(
        call_abi_method="set_boxes",
        a=56,
        b=b"Hello",
        c="World",
        transaction_parameters=transaction_parameters,
    )

    (a_exist, b_exist, c_exist) = box_client.call(
        call_abi_method="boxes_exist",
        transaction_parameters=transaction_parameters,
    ).return_value
    assert a_exist
    assert b_exist
    assert c_exist

    box_client.call("check_keys", transaction_parameters=transaction_parameters)

    (a, b, c) = box_client.call(
        call_abi_method="read_boxes",
        transaction_parameters=transaction_parameters,
    ).return_value

    assert (a, bytes(b), c) == (59, b"Hello", "World")

    box_client.call("delete_boxes", transaction_parameters=transaction_parameters)

    (a_exist, b_exist, c_exist) = box_client.call(
        call_abi_method="boxes_exist",
        transaction_parameters=transaction_parameters,
    ).return_value
    assert not a_exist
    assert not b_exist
    assert not c_exist

    box_client.call(
        call_abi_method="slice_box", transaction_parameters=_params_with_boxes(b"0", box_c)
    )

    box_client.call(call_abi_method="arc4_box", transaction_parameters=_params_with_boxes(b"d"))

def test_box_ref(box_client: algokit_utils.ApplicationClient) -> None:
    box_client.call(
        call_abi_method="test_box_ref",
        transaction_parameters=_params_with_boxes("box_ref", b"blob", additional_refs=6),
    )

def test_box_map(box_client: algokit_utils.ApplicationClient) -> None:
    box_client.call(
        call_abi_method="box_map_test",
        transaction_parameters=_params_with_boxes(0, 1),
    )

    key = 2
    transaction_parameters = _params_with_boxes(key)
    assert not box_client.call(
        call_abi_method="box_map_exists",
        key=key,
        transaction_parameters=transaction_parameters,
    ).return_value, "Box does not exist (yet)"
    box_client.call(
        call_abi_method="box_map_set",
        key=key,
        value="Hello 123",
        transaction_parameters=transaction_parameters,
    )
    assert (
        box_client.call(
            call_abi_method="box_map_get",
            key=key,
            transaction_parameters=transaction_parameters,
        ).return_value
        == "Hello 123"
    ), "Box value is what was set"

    assert box_client.call(
        call_abi_method="box_map_exists",
        key=key,
        transaction_parameters=transaction_parameters,
    ).return_value, "Box exists"

    box_client.call(
        call_abi_method="box_map_del",
        key=key,
        transaction_parameters=transaction_parameters,
    )

    assert not box_client.call(
        call_abi_method="box_map_exists",
        key=key,
        transaction_parameters=transaction_parameters,
    ).return_value, "Box does not exist after deletion"

def test_compile(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(TEST_CASES_DIR / "compile", file_name="factory.py")
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    app_client.create()

    increased_fee = algod_client.suggested_params()
    increased_fee.flat_fee = True
    increased_fee.fee = constants.min_txn_fee * 6
    txn_params = algokit_utils.OnCompleteCallParameters(suggested_params=increased_fee)
    algokit_utils.config.config.configure(debug=True, trace_all=True)
    app_client.call("test_compile_contract", transaction_parameters=txn_params)
    app_client.call("test_compile_contract_tmpl", transaction_parameters=txn_params)
    app_client.call("test_compile_contract_prfx", transaction_parameters=txn_params)
    app_client.call("test_compile_contract_large", transaction_parameters=txn_params)

    app_client.call("test_arc4_create", transaction_parameters=txn_params)
    app_client.call("test_arc4_create_tmpl", transaction_parameters=txn_params)
    app_client.call("test_arc4_create_prfx", transaction_parameters=txn_params)
    app_client.call("test_arc4_create_large", transaction_parameters=txn_params)
    app_client.call("test_arc4_create_modified_compiled", transaction_parameters=txn_params)
    app_client.call("test_arc4_update", transaction_parameters=txn_params)
    app_client.call("test_other_constants", transaction_parameters=txn_params)
    app_client.call("test_abi_call_create_params", transaction_parameters=txn_params)

def test_nested_tuples(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    example = TEST_CASES_DIR / "tuple_support" / "nested_tuples.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    app_client.create()

    app_client.call("run_tests")

    response = app_client.call("nested_tuple_params", args=("Hello", (b"World", (123,))))

    assert bytes(response.return_value[0]) == b"World"
    assert response.return_value[1][0] == "Hello"
    assert response.return_value[1][1] == 123

    response = app_client.call("named_tuple", args=(1, b"2", "3"))
    assert response.return_value == [1, [50], "3"]

    response = app_client.call("nested_named_tuple_params", args=(1, 2, (3, b"4", "5")))
    assert response.return_value == [1, 2, [3, [52], "5"]]

def test_named_tuples(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    example = TEST_CASES_DIR / "named_tuples" / "contract.py"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create
    app_client.create()

    result = app_client.call("build_tuple", a=12, b=343459043, c="hello 123", d=account.public_key)

    (a, b, c, d) = result.return_value

    app_client.call("test_tuple", value=(a, b, c, d))

    app_client.call(
        "test_tuple", value={"a": 34, "b": 53934433, "c": "hmmmm", "d": account.public_key}
    )

def test_group_side_effects(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    test_dir = TEST_CASES_DIR / "group_side_effects"
    other_app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(test_dir / "other.py")
    )
    app_spec = algokit_utils.ApplicationSpecification.from_json(
        compile_arc32(test_dir / "contract.py")
    )
    other_app_client = algokit_utils.ApplicationClient(
        algod_client, other_app_spec, signer=account
    )
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create app
    app_client.create()
    sp = algod_client.suggested_params()

    # compose group with asset and app create txns
    asset_create_txn = TransactionWithSigner(
        transaction.AssetCreateTxn(
            account.address,
            sp,
            10_000_000,
            0,
            default_frozen=False,
            asset_name="asset group",
            unit_name="AGRP",
        ),
        signer=account.signer,
    )
    atc = AtomicTransactionComposer()
    other_app_client.compose_create(atc)
    app_create_txn = atc.txn_list[-1]

    # call app with create txns
    response = app_client.call(
        "create_group", asset_create=asset_create_txn, app_create=app_create_txn
    )
    asset_id, other_app_id = response.return_value

    # compose group with app call
    other_app_client.app_id = other_app_id
    atc = AtomicTransactionComposer()
    other_app_client.compose_call(atc, "some_value")
    app_call_txn = atc.txn_list[-1]
    app_client.call("log_group", app_call=app_call_txn)

def test_state_mutations(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
) -> None:
    test_dir = TEST_CASES_DIR / "state_mutations"
    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(test_dir))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)

    # create app
    app_client.create()
    algokit_utils.ensure_funded(
        algod_client,
        algokit_utils.EnsureBalanceParameters(
            account_to_fund=app_client.app_address,
            min_spending_balance_micro_algos=200_000,
        ),
    )
    box_key = b"box"
    map_key = b"map" + account.public_key
    txn_params: algokit_utils.OnCompleteCallParametersDict = {
        "boxes": [(0, box_key), (0, map_key)]
    }
    app_client.opt_in(transaction_parameters=txn_params)
    response = app_client.call("get", transaction_parameters=txn_params)
    assert response.return_value == []

    # append
    app_client.call("append", transaction_parameters=txn_params)
    response = app_client.call("get", transaction_parameters=txn_params)
    assert response.return_value == [[1, "baz"]]

    # modify
    app_client.call("modify", transaction_parameters=txn_params)
    response = app_client.call("get", transaction_parameters=txn_params)
    assert response.return_value == [[1, "modified"]]

    # append
    app_client.call("append", transaction_parameters=txn_params)
    response = app_client.call("get", transaction_parameters=txn_params)
    assert response.return_value == [[1, "modified"], [1, "baz"]]

@pytest.mark.parametrize(
    ("file_name", "expected_init_log", "expected_method_log"),
    [
        (
            "base1",
            ["base1.__init__", "gp.__init__"],
            ["base1.method", "gp.method"],
        ),
        (
            "base2",
            ["base2.__init__", "gp.__init__"],
            ["base2.method", "gp.method"],
        ),
        (
            "derived",
            ["derived.__init__", "base1.__init__", "base2.__init__", "gp.__init__"],
            ["derived.method", "base1.method", "base2.method", "gp.method"],
        ),
    ],
)
def test_diamond_mro(
    algod_client: AlgodClient,
    account: algokit_utils.Account,
    file_name: str,
    expected_init_log: list[str],
    expected_method_log: list[str],
) -> None:
    example = TEST_CASES_DIR / "diamond_mro" / f"{file_name}.py"

    app_spec = algokit_utils.ApplicationSpecification.from_json(compile_arc32(example))
    app_client = algokit_utils.ApplicationClient(algod_client, app_spec, signer=account)
    init_response = app_client.create(call_abi_method=True)
    init_logs_raw = init_response.tx_info["logs"]
    init_logs = decode_logs(init_logs_raw, len(init_logs_raw) * "u")
    assert init_logs == expected_init_log

    method_response = app_client.call(call_abi_method="method")
    method_logs_raw = method_response.tx_info["logs"]
    method_logs = decode_logs(method_logs_raw, len(method_logs_raw) * "u")
    assert method_logs == expected_method_log


from collections.abc import Mapping

import algosdk.error
import pytest
from algokit_utils import Program
from algosdk.v2client.algod import AlgodClient

from puya.compilation_artifacts import CompiledContract, CompiledLogicSig, CompiledProgram
from puya.context import CompileContext
from puya.mir.models import Signature
from puya.options import PuyaOptions
from puya.program_refs import LogicSigReference
from puya.teal import models as teal
from puya.ussemble.main import assemble_program
from puyapy.options import PuyaPyOptions
from tests.utils import (
    PuyaTestCase,
    compile_src_from_options,
    load_template_vars,
)

@pytest.mark.parametrize("optimization_level", [0, 1, 2])
@pytest.mark.localnet
def test_assemble_matches_algod(
    algod_client: AlgodClient, test_case: PuyaTestCase, optimization_level: int
) -> None:
    prefix, template_vars = load_template_vars(test_case.template_vars_path)
    options = PuyaPyOptions(
        paths=(test_case.path,),
        optimization_level=optimization_level,
        debug_level=0,
        output_bytecode=True,
        template_vars_prefix=prefix,
        cli_template_definitions=template_vars,
    )
    compile_result = compile_src_from_options(options)
    for artifact in compile_result.teal:
        match artifact:
            case CompiledContract(approval_program=approval, clear_program=clear):
                assemble_and_compare_program(
                    options, algod_client, approval, f"{artifact.metadata.ref}-approval"
                )
                assemble_and_compare_program(
                    options, algod_client, clear, f"{artifact.metadata.ref}-clear"
                )
            case CompiledLogicSig(program=logic_sig):
                assemble_and_compare_program(
                    options, algod_client, logic_sig, f"{artifact.metadata.ref}-logicsig"
                )

def assemble_and_compare_program(
    options: PuyaOptions,
    algod_client: AlgodClient,
    compiled_program: CompiledProgram,
    name: str,
) -> None:
    puya_program = compiled_program.bytecode
    assert puya_program is not None
    template_values = {k: _template_value_as_str(v) for k, v in options.template_variables.items()}
    teal_src = "\n".join(
        _replace_template_variables(line, template_values)
        for line in compiled_program.teal_src.splitlines()
    )
    algod_program_ = Program(teal_src, algod_client)
    algod_program = algod_program_.raw_binary

    expected = algod_program.hex()
    actual = puya_program.hex()
    if expected != actual:
        # attempt to decompile both to compare, but revert to byte code if puya can't
        # even be disassembled
        try:
            puya_dis = algod_client.disassemble(puya_program)["result"]
        except algosdk.error.AlgodHTTPError:
            pass
        else:
            expected = algod_client.disassemble(algod_program)["result"]
            actual = puya_dis
    assert actual == expected, f"{name} bytecode does not match algod bytecode"

def _template_value_as_str(value: int | bytes) -> str:
    if isinstance(value, int):
        return repr(value)
    return "0x" + value.hex()

def _replace_template_variables(line: str, template_values: Mapping[str, str]) -> str:
    for var, value in template_values.items():
        line = line.replace(var, value, 1)
    return line

def test_assemble_last_op_jump() -> None:
    """Verifies edge case where final op of a program is a branch op"""

    # construct a block that is terminated with a branch, by jumping to the block's label
    looping_block = teal.TealBlock(
        label="start",
        ops=[
            teal.Intrinsic(
                op_code="b",
                immediates=["start"],
                consumes=0,
                produces=0,
                source_location=None,
            )
        ],
        x_stack_in=(),
        entry_stack_height=0,
        exit_stack_height=0,
    )

    # verify program assembles correctly
    bytecode = assemble_program(
        CompileContext(
            options=PuyaOptions(),
            compilation_set={},
            sources_by_path={},
        ),
        LogicSigReference(),
        program=teal.TealProgram(
            avm_version=10,
            main=teal.TealSubroutine(
                is_main=True,
                signature=Signature(name="", parameters=(), returns=()),
                blocks=[looping_block],
                source_location=None,
            ),
            subroutines=[],
        ),
    ).bytecode

    assert bytecode == b"".join(
        (
            b"\x0a",  # version 10
            b"B",  # branch
            (-3).to_bytes(length=2, signed=True),  # offset
        )
    )


from puya.awst import nodes, serialize
from puya.awst.to_code_visitor import ToCodeVisitor
from tests.utils import PuyaTestCase, get_awst_cache

def test_cattrs(test_case: PuyaTestCase) -> None:
    cache = get_awst_cache(test_case.root)
    awst = [a for a in cache.module_awst if _is_case_awst(test_case, a)]
    json = serialize.awst_to_json(awst)
    cloned = serialize.awst_from_json(json)
    assert len(cloned) == len(awst)

    assert ToCodeVisitor().visit_module(cloned) == ToCodeVisitor().visit_module(awst)
    for cloned_, awst_ in zip(cloned, awst, strict=True):
        assert cloned_ == awst_

def _is_case_awst(case: PuyaTestCase, awst: nodes.RootNode) -> bool:
    path = awst.source_location.file
    return bool(path and path.is_relative_to(case.path))


import os
import shutil
import subprocess
from pathlib import Path

from tests import EXAMPLES_DIR, TEST_CASES_DIR, VCS_ROOT

ENV_WITH_NO_COLOR = dict(os.environ) | {
    "NO_COLOR": "1",  # disable colour output
    "PYTHONUTF8": "1",  # force utf8 on windows
}

def run_puyapy(
    args: list[str | Path], *, check: bool = True, text: bool = True
) -> subprocess.CompletedProcess[str]:
    puyapy = shutil.which("puyapy")
    assert puyapy is not None
    result = subprocess.run(
        [
            puyapy,
            # we don't want to overwrite outputs, just check the CLI works,
            "--no-output-teal",
            "--no-output-bytecode",
            "--no-output-source-map",
            "--no-output-client",
            "--no-output-arc32",
            *map(str, args),
        ],
        text=text,
        # capture stdout
        stdout=subprocess.PIPE,
        # redirect stderr to stdout, so they're interleaved in the correct ordering
        stderr=subprocess.STDOUT,
        cwd=VCS_ROOT,
        check=False,
        env=ENV_WITH_NO_COLOR,
    )
    if check:
        assert result.returncode == 0, result.stdout
    return result

def run_puyapy_clientgen(
    path: Path,
    *flags: str,
) -> subprocess.CompletedProcess[str]:
    puyapy_clientgen = shutil.which("puyapy-clientgen")
    assert puyapy_clientgen is not None
    result = subprocess.run(
        [
            puyapy_clientgen,
            str(path),
            *flags,
        ],
        text=True,
        # capture stdout
        stdout=subprocess.PIPE,
        # redirect stderr to stdout, so they're interleaved in the correct ordering
        stderr=subprocess.STDOUT,
        cwd=VCS_ROOT,
        check=False,
        env=ENV_WITH_NO_COLOR,
    )
    assert result.returncode == 0, result.stdout
    return result

def test_run_no_args() -> None:
    result = run_puyapy([], check=False)
    assert result.returncode == 2

def test_run_version() -> None:
    result = run_puyapy(["--version"], check=False)
    assert result.returncode == 0
    assert result.stdout.startswith("puyapy")

def test_run_not_python() -> None:
    result = run_puyapy(["pyproject.toml"], check=False)
    assert result.returncode == 1
    assert "error: invalid syntax" in result.stdout

def test_run_single_file() -> None:
    run_puyapy([TEST_CASES_DIR / Path("simple") / "contract.py"])

def test_run_single_file_with_other_references(tmpdir: Path) -> None:
    run_puyapy(
        [
            TEST_CASES_DIR / "compile" / "factory.py",
            "--output-bytecode",
            "--out-dir",
            str(tmpdir),
        ]
    )

def test_run_multiple_files() -> None:
    run_puyapy(
        [
            EXAMPLES_DIR / Path("calculator") / "contract.py",
            TEST_CASES_DIR / Path("simple") / "contract.py",
        ]
    )

def test_run_directory() -> None:
    run_puyapy([TEST_CASES_DIR / "simple"])

def test_puyapy_clientgen_arc32(tmpdir: Path) -> None:
    # ARC-32 output differs slightly from ARC-56, so we are just checking it doesn't error
    # and ignore the generated artifact
    path = TEST_CASES_DIR / "arc_56" / "out" / "Contract.arc32.json"
    run_puyapy_clientgen(path, "--out-dir", str(tmpdir))

def test_puyapy_clientgen_arc56() -> None:
    path = TEST_CASES_DIR / "arc_56" / "out" / "Contract.arc56.json"
    run_puyapy_clientgen(path)


import json
import os
import shutil
import subprocess
import typing
from pathlib import Path

import attrs

from puya import log
from puyapy.options import PuyaPyOptions
from tests import VCS_ROOT
from tests.utils import (
    APPROVAL_EXTENSIONS,
    PuyaTestCase,
    compile_src_from_options,
    get_relative_path,
    load_template_vars,
)

ENV_WITH_NO_COLOR = dict(os.environ) | {
    "NO_COLOR": "1",  # disable colour output
    "PYTHONUTF8": "1",  # force utf8 on windows
}
SUFFIX_O0 = "_unoptimized"
SUFFIX_O1 = ""
SUFFIX_O2 = "_O2"

def test_compile(test_case: PuyaTestCase) -> None:
    _remove_output(test_case.path)
    _compile_no_optimization(test_case)
    _compile_with_level1_optimizations(test_case)
    _compile_with_level2_optimizations(test_case)
    diff = _check_for_diff(test_case.path)
    assert not diff, f"Uncommitted changes were found:\n{diff}"

def _compile_test_case(
    test_case: PuyaTestCase, suffix: str, log_path: Path | None = None, **options: typing.Any
) -> None:
    path = test_case.path
    assert path.is_dir()
    dst_out_dir = path / ("out" + suffix)

    prefix, template_vars = load_template_vars(test_case.template_vars_path)
    puya_options = PuyaPyOptions(
        paths=(test_case.path,),
        out_dir=dst_out_dir,
        log_level=log.LogLevel.debug,
        template_vars_prefix=prefix,
        cli_template_definitions=template_vars,
        **options,
    )
    compile_result = compile_src_from_options(puya_options)

    if log_path:
        root_dir = compile_result.root_dir
        log_options = attrs.evolve(puya_options, paths=(Path(test_case.name),))
        logs = [_log_to_str(log_, root_dir) for log_ in compile_result.logs]
        logs.insert(0, f"debug: {log_options}")
        log_path.write_text(
            "\n".join(map(_normalize_log, logs)),
            encoding="utf8",
        )

    # normalize ARC-56 output
    for arc56_file in dst_out_dir.rglob("*.arc56.json"):
        _normalize_arc56(arc56_file)

def _normalize_path(path: Path | str) -> str:
    return str(path).replace("\\", "/")

_NORMALIZED_VCS = _normalize_path(VCS_ROOT)

def _normalize_log(log: str) -> str:
    return log.replace("\\", "/").replace(_NORMALIZED_VCS, "<git root>")

def _log_to_str(log: log.Log, root_dir: Path) -> str:
    if log.location and log.location.file:
        relative_path = get_relative_path(log.location, root_dir)
        col = f":{log.location.column + 1}" if log.location.column else ""
        location = f"{relative_path!s}:{log.location.line}{col} "
    else:
        location = ""
    return f"{location}{log.level}: {log.message}"

def _compile_no_optimization(test_case: PuyaTestCase) -> None:
    _compile_test_case(
        test_case,
        SUFFIX_O0,
        optimization_level=0,
        output_teal=True,
        output_bytecode=True,
        output_destructured_ir=True,
    )

def _compile_with_level1_optimizations(test_case: PuyaTestCase) -> None:
    _compile_test_case(
        test_case,
        SUFFIX_O1,
        log_path=test_case.path / "puya.log",
        optimization_level=1,
        output_teal=True,
        output_bytecode=True,
        output_source_map=True,
        output_arc32=True,
        output_arc56=True,
        output_awst=True,
        output_ssa_ir=True,
        output_optimization_ir=True,
        output_destructured_ir=True,
        output_memory_ir=True,
        output_client=True,
    )

def _compile_with_level2_optimizations(test_case: PuyaTestCase) -> None:
    _compile_test_case(
        test_case,
        SUFFIX_O2,
        optimization_level=2,
        debug_level=0,
        output_teal=True,
        output_bytecode=True,
        output_destructured_ir=True,
    )

def _remove_output(path: Path) -> None:
    (path / "puya.log").unlink(missing_ok=True)
    for out_suffix in (SUFFIX_O0, SUFFIX_O1, SUFFIX_O2):
        out_dir = path / f"out{out_suffix}"
        if out_dir.exists():
            for file in out_dir.iterdir():
                if file.suffix in APPROVAL_EXTENSIONS:
                    if file.is_dir():
                        shutil.rmtree(file)
                    else:
                        file.unlink()

def _check_for_diff(path: Path) -> str | None:
    git = shutil.which("git")
    assert git, "could not find git"
    assert path.is_dir()
    result = subprocess.run(
        [git, "diff", str(path)],
        check=True,
        capture_output=True,
        cwd=VCS_ROOT,
    )
    return result.stdout.decode("utf8")

def _normalize_arc56(path: Path) -> None:
    arc56 = json.loads(path.read_text())
    compiler_version = arc56.get("compilerInfo", {}).get("compilerVersion", {})
    compiler_version["major"] = 99
    compiler_version["minor"] = 99
    compiler_version["patch"] = 99
    path.write_text(json.dumps(arc56, indent=4), encoding="utf8")


import typing

import algokit_utils
import algokit_utils.config
import attrs
import pytest
from algosdk.atomic_transaction_composer import (
    AtomicTransactionComposer,
)
from algosdk.source_map import SourceMap
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.models import SimulateRequest, SimulateTraceConfig

from puya.arc32 import create_arc32_json
from puya.compilation_artifacts import CompiledContract, DebugInfo
from puyapy.options import PuyaPyOptions
from tests import TEST_CASES_DIR
from tests.utils import compile_src_from_options

pytestmark = pytest.mark.localnet

def test_debug(algod_client: AlgodClient, account: algokit_utils.Account) -> None:
    contract_src = TEST_CASES_DIR / "debug" / "contract.py"
    result = compile_src_from_options(
        PuyaPyOptions(
            paths=(contract_src,),
            optimization_level=1,
            debug_level=2,
        )
    )
    (contract,) = result.teal
    assert isinstance(contract, CompiledContract), "Compilation artifact must be a contract"
    approval_src = algokit_utils.replace_template_variables(
        contract.approval_program.teal_src, {"A_MULT": 1000}
    )
    clear_src = contract.clear_program.teal_src
    arc32 = create_arc32_json(approval_src, clear_src, contract.metadata)
    app_spec = algokit_utils.ApplicationSpecification.from_json(arc32)
    client = algokit_utils.ApplicationClient(
        algod_client,
        app_spec,
        signer=account,
    )
    client.create()

    atc = AtomicTransactionComposer()
    client.compose_call(atc, "test", x=1, y=2, z=3)
    simulate_request = SimulateRequest(
        txn_groups=[],
        allow_more_logs=True,
        allow_empty_signatures=True,
        exec_trace_config=SimulateTraceConfig(
            enable=True,
            stack_change=True,
            scratch_change=True,
            state_change=True,
        ),
    )
    response = atc.simulate(algod_client, simulate_request)
    approval_debug_map = contract.approval_program.debug_info
    assert approval_debug_map is not None
    clear_debug_map = contract.clear_program.debug_info
    assert clear_debug_map is not None
    assert client.approval
    _check_teal_map_with_puya_map(client.approval.source_map, approval_debug_map)
    _check_trace_with_puya_map(response.simulate_response, approval_debug_map)

    assert client.clear
    _check_teal_map_with_puya_map(client.clear.source_map, clear_debug_map)

Json = dict[str, typing.Any]

def _check_teal_map_with_puya_map(teal_source_map: SourceMap, puya_map: DebugInfo) -> None:
    op_pc_offset = puya_map.op_pc_offset
    # if a non-zero offset is provided increment by 1 to account for version byte in line_to_pc map
    if op_pc_offset:
        op_pc_offset += 1
    line, pcs = sorted(teal_source_map.line_to_pc.items())[op_pc_offset]
    pc_offset = pcs[0]

    puya_source_map = SourceMap(attrs.asdict(puya_map))
    events = puya_map.pc_events
    max_puya_pc = max(puya_source_map.pc_to_line) + pc_offset
    assert max_puya_pc in teal_source_map.pc_to_line, "expected max puya pc to be in teal src map"
    assert (
        max_puya_pc + 1 not in teal_source_map.pc_to_line
    ), "expected teal pc to not go beyond puya pc"
    missing_event_pcs = [
        pc
        for pc in (int(pc_str) + pc_offset for pc_str in events)
        if pc not in teal_source_map.pc_to_line
    ]
    assert not missing_event_pcs, "expected all event pcs to be in teal source map"

def _check_trace_with_puya_map(simulate: Json, puya_map: DebugInfo) -> None:
    op_pc_offset = puya_map.op_pc_offset
    approval_trace = simulate["txn-groups"][0]["txn-results"][0]["exec-trace"][
        "approval-program-trace"
    ]
    offset_trace = approval_trace[op_pc_offset]
    pc_offset = offset_trace["pc"]
    pc_events = {int(pc): event for pc, event in puya_map.pc_events.items()}
    missing_pcs = [
        pc
        for pc in (int(pc_trace["pc"]) for pc_trace in approval_trace)
        if pc >= pc_offset and (pc - pc_offset) not in pc_events
    ]
    assert not missing_pcs, "expected relative pcs to be in pc_events"