@compiler >= 6

include "List.aes"
include "Option.aes"
include "Set.aes"
include "String.aes"
include "./core/utils.aes"
include "./core/IAEX141NFTReceiver.aes"

contract KnowledgeNFTs =

    datatype event
        // aex141 default events
        = Transfer(address, address, int) // from, to, token_id
        | Approval(address, address, int, string) // owner, approved_account, token_id, enabled ("true"/"false")
        | ApprovalForAll(address, address, string) // owner, operator, approved ("true"/"false")
        // mintable extension
        | Mint(address, int) // to, token_id
        // mintable_limit extension
        | TokenLimit(int) // limit
        | TokenLimitDecrease(int, int) // old_limit, new_limit
        // burnable extension
        | Burn(address, int) // owner, token_id

    datatype metadata_type = URL | OBJECT_ID | MAP
    datatype metadata = MetadataIdentifier(string) | MetadataMap(map(string, string))

    record meta_info =
        { name: string
        , symbol: string
        , base_url: option(string)
        , metadata_type : metadata_type }
    
    record state =
        { owner: address
        , meta_info: meta_info
        , token_to_owners: map(int, address)
        , owners_to_tokens: map(address, Set.set(int))
        , balances: map(address, int)
        , approvals: map(int, address)
        , operators: map(address, map(address, bool))
        , metadata: map(int, metadata)
        , total_supply: int
        , token_limit: int
        , counter: int }

    stateful entrypoint init(name: string, symbol: string, token_limit: int) =
        require(String.length(name) >= 1, "STRING_TOO_SHORT_NAME")
        require(String.length(symbol) >= 1, "STRING_TOO_SHORT_SYMBOL")
        require(token_limit > 0, "TOKEN_LIMIT_INVALID")
        Chain.event(TokenLimit(token_limit))
        { owner = Contract.creator,
          meta_info = { name = name, symbol = symbol, base_url = None, metadata_type = MAP },
          token_to_owners = {},
          owners_to_tokens = {},
          balances = {},
          approvals = {},
          operators = {},
          metadata = {},
          total_supply = 0,
          token_limit = token_limit,
          counter = 1 }

    entrypoint aex141_extensions() : list(string) =
        ["mintable", "mintable_limit", "burnable"]

    entrypoint meta_info() : meta_info =
        state.meta_info

    entrypoint metadata(token_id: int) : option(metadata) =
        Map.lookup(token_id, state.metadata)

    entrypoint total_supply() : int =
        state.total_supply

    entrypoint balance(owner: address) : option(int) =
        Map.lookup(owner, state.balances)

    entrypoint owner(token_id: int) : option(address) =
        Map.lookup(token_id, state.token_to_owners)

    stateful entrypoint transfer(to: address, token_id: int, data: option(string)) =
        let from = require_authorized(token_id)
        require(from != to, "SENDER_MUST_NOT_BE_RECEIVER")
        remove_approval(token_id)
        put(state{ balances[from] @balance1 = balance1 - 1
            , balances[to = 0] @balance2 = balance2 + 1
            , token_to_owners[token_id] = to
            , owners_to_tokens[from] @from_tokens = Set.delete(token_id, from_tokens)
            , owners_to_tokens[to = Set.new()] @to_tokens = Set.insert(token_id, to_tokens) })
        switch(invoke_nft_receiver(Some(from), to, token_id, data))
            (true, false) => abort("SAFE_TRANSFER_FAILED")
            _ => Chain.event(Transfer(from, to, token_id))

    stateful entrypoint transfer_to_contract(token_id: int) =
        let to = Call.caller
        require(Address.is_contract(to), "CALLER_MUST_BE_A_CONTRACT")
        let from = require_authorized(token_id)
        require(from != to, "SENDER_MUST_NOT_BE_RECEIVER")
        remove_approval(token_id)
        put(state{ balances[from] @balance1 = balance1 - 1
            , balances[to = 0] @balance2 = balance2 + 1
            , token_to_owners[token_id] = to
            , owners_to_tokens[from] @from_tokens = Set.delete(token_id, from_tokens)
            , owners_to_tokens[to = Set.new()] @to_tokens = Set.insert(token_id, to_tokens) })
        Chain.event(Transfer(from, to, token_id))

    stateful entrypoint approve(approved: address, token_id: int, enabled: bool) =
        require_authorized(token_id)
        if(enabled)
            put(state{approvals[token_id] = approved})
        else
            remove_approval(token_id)
        Chain.event(Approval(Call.caller, approved, token_id, Utils.bool_to_string(enabled)))

    stateful entrypoint approve_all(operator: address, enabled: bool) =
        put(state{operators @ ops = ops{[Call.caller = {}] @ op = op{[operator] = enabled}}})
        Chain.event(ApprovalForAll(Call.caller, operator, Utils.bool_to_string(enabled)))

    entrypoint get_approved(token_id: int) : option(address) =
        Map.lookup(token_id, state.approvals)

    entrypoint is_approved(token_id: int, a: address) : bool =
        switch(Map.lookup(token_id, state.approvals))
            None => false
            Some(o) => o == a

    entrypoint is_approved_for_all(owner: address, operator: address) : bool =
        switch(Map.lookup(owner, state.operators))
            None => false
            Some(ops) =>
                switch(Map.lookup(operator, ops))
                    None => false
                    Some(v) => v

    // mintable extension

    stateful entrypoint mint(to: address, metadata: option(metadata), data: option(string)) : int =  
        require_contract_owner()
        if(state.token_limit == state.total_supply)
            abort("TOKEN_LIMIT_REACHED")
        switch(metadata)
            None => abort("NO_METADATA_PROVIDED")
            Some(MetadataIdentifier(_)) => abort("NOT_METADATA_MAP")
            Some(v) =>
                let token_id = state.counter
                put(state{ counter = state.counter + 1
                    , total_supply = state.total_supply + 1
                    , balances[to = 0] @balance = balance + 1
                    , token_to_owners[token_id] = to
                    , owners_to_tokens[to = Set.new()] @owners_tokens = Set.insert(token_id, owners_tokens)
                    , metadata[token_id] = v })
                switch(invoke_nft_receiver(None, to, token_id, data))
                    (true, false) => abort("SAFE_TRANSFER_FAILED")
                    _ => Chain.event(Mint(to, token_id))
                token_id

    // mintable_limit extension

    entrypoint token_limit() : int =
        state.token_limit

    stateful entrypoint decrease_token_limit(new_limit: int) =
        require(new_limit > 0, "TOKEN_LIMIT_INVALID")
        require_contract_owner()
        let old_limit = state.token_limit
        if(new_limit > old_limit)
            abort("TOKEN_LIMIT_INCREASE_NOT_ALLOWED")
        if(new_limit == old_limit)
            abort("NO_CHANGE_PROVIDED")
        if(new_limit < state.total_supply)
            abort("MORE_TOKENS_ALREADY_IN_EXISTENCE")
        put(state{token_limit = new_limit})
        Chain.event(TokenLimitDecrease(old_limit, new_limit))

    // burnable extension

    stateful entrypoint burn(token_id: int) =
        let owner = require_authorized(token_id)
        remove_approval(token_id)
        put(state{ balances[owner] @balance = balance - 1
            , total_supply = state.total_supply - 1
            , token_to_owners = Map.delete(token_id, state.token_to_owners)
            , owners_to_tokens[owner] @owners_tokens = Set.delete(token_id, owners_tokens) })
        Chain.event(Burn(owner, token_id))

    // not part of AEX-141

    entrypoint get_owned_tokens(owner: address) : list(int) =
        Set.to_list(Map.lookup_default(owner, state.owners_to_tokens, Set.new()))

    // internal helper functions

    function require_contract_owner() =
        require(Call.caller == state.owner, "ONLY_CONTRACT_OWNER_CALL_ALLOWED")

    function require_authorized(token_id: int) : address =
        let owner = switch(owner(token_id))
            None => abort("TOKEN_NOT_EXISTS")
            Some(v) => v
        require(Call.caller == owner || is_approved(token_id, Call.caller) || is_approved_for_all(owner, Call.caller), "ONLY_OWNER_APPROVED_OR_OPERATOR_CALL_ALLOWED")
        owner

    function is_token_owner(token_id: int, a: address) : bool =
        switch(Map.lookup(token_id, state.token_to_owners))
            None => false
            Some(o) => o == a

    stateful function remove_approval(token_id: int) =
        if(Map.member(token_id, state.approvals))
            put(state{ approvals = Map.delete(token_id, state.approvals) })

    stateful function burn_internal(owner: address, token_id: int) =
        put(state{ balances[owner] @owner_balance = owner_balance - 1
            , total_supply = state.total_supply - 1
            , token_to_owners = Map.delete(token_id, state.token_to_owners)
            , owners_to_tokens[owner] @owners_tokens = Set.delete(token_id, owners_tokens) })

    function invoke_nft_receiver(from: option(address), to: address, token_id: int, data: option(string)) : (bool * bool) =
        if(Address.is_contract(to))
            let c = Address.to_contract(to)
            switch(c.on_aex141_received(from, token_id, data, protected = true) : option(bool))
                None => (true, false)
                Some(val) => (true, val)
        else
            (false, false)
