import { ethers } from "ethers";
import { EthereumContractService } from "../contract";
import { OPPAssertion } from "../../../types";


export class OPPClient {

    
    private readonly decoders: Record<number, Decoder> = {
        2001: (d) => this.decode2001(d),
        2002: (d) => this.decode2002(d),

        3001: (d) => this.decodeStake3001(d),
        3002: (d) => this.decodeUnstake3002(d),
        // 3003 not implemented
        3004: (d) => this.decodeLiqPretoken3004(d),
        3005: (d) => this.decodePretoken3005(d),
        3006: (d) => this.decodeYieldPretoken3006(d),
    };
    
    private readonly contractService: EthereumContractService;
    
    get contract() { return this.contractService.contract; }

    constructor(contract: EthereumContractService) {
        this.contractService = contract;
    }
    


    /**
     * Read OPP / Outpost state used by the Depositor to decide whether staking is allowed.
     * Returns various data
     */
    async getStatus(): Promise<any> {
        const depositor = this.contract.Depositor;
        const opp = this.contract.OPP;

        
        const oppAddress: string = await depositor.oppAddress();
        const oppInboundAddress: string = await depositor.oppInboundAddress();
        const prevEpochSent = await opp.prevEpochSent();
        

        const inbound = this.contractService.getReadOnly('OPPInbound');

        // Query useful getters
        const nextEpochBN: any = await inbound.nextEpochNum();
        const pendingEpochRaw: any = await inbound.pendingEpoch();
        const pendingMessageCount: any = await inbound.pendingMessageCount();
        const previousEpochHash: string = await inbound.previousEpochHash();
        const nextEpochNum = (nextEpochBN && typeof nextEpochBN.toNumber === 'function') ? nextEpochBN.toNumber() : Number(nextEpochBN || 0);

        const pendingEpoch = (pendingEpochRaw && pendingEpochRaw.epochNumber !== undefined)
            ? {
                epochNumber: (pendingEpochRaw.epochNumber && typeof pendingEpochRaw.epochNumber.toNumber === 'function') ? pendingEpochRaw.epochNumber.toNumber() : Number(pendingEpochRaw.epochNumber || 0),
                timestamp: (pendingEpochRaw.timestamp && typeof pendingEpochRaw.timestamp.toNumber === 'function') ? pendingEpochRaw.timestamp.toNumber() : Number(pendingEpochRaw.timestamp || 0),
                prevEpochHash: pendingEpochRaw.prevEpochHash,
                merkleRoot: pendingEpochRaw.merkleRoot,
                firstMessageID: pendingEpochRaw.firstMessageID,
                lastMessageID: pendingEpochRaw.lastMessageID,
            }
            : null;

        const pendingMessagesBN = pendingMessageCount;
        const pendingMessages = (pendingMessagesBN && typeof pendingMessagesBN.toString === 'function') ? pendingMessagesBN.toString() : String(pendingMessagesBN || '0');

        const hasPendingMessages = (pendingMessagesBN && typeof pendingMessagesBN.gt === 'function') ? pendingMessagesBN.gt(0) : (Number(pendingMessages) > 0);

        return {
            oppAddress,
            prevEpochSent,
            oppInboundAddress,
            nextEpochNum,
            pendingEpoch,
            pendingMessageCount: pendingMessages,
            previousEpochHash,
            hasPendingMessages,
            raw: {
                nextEpochBN,
                pendingEpochRaw,
                pendingMessageCount: pendingMessagesBN,
            },
        };
    }

    /**
     * Fetches all OPPMessage events and flattens all assertions into a single OPPAssertion[] array.
     */
    async getMessages(address: string): Promise<OPPAssertion[]> {
        try {
            const oppMessageFilter = this.contract.OPP.filters.OPPMessage();
            const events = await this.contract.OPP.queryFilter(oppMessageFilter, 0, "latest");
            const allAssertions: OPPAssertion[] = [];
            for (const event of events) {
                const assertions = await this.extractAssertionsFromEvent(event);
                allAssertions.push(...assertions);
            }

            // ! Current implementation is not ideal - no current way to filter OPP Messages by a single user
            const normalized = address ? address.toLowerCase() : null;
            const filtered = allAssertions.filter(a =>
                (a.from && a.from.toLowerCase() === normalized) ||
                (a.to && a.to.toLowerCase() === normalized)
            );
            return filtered.reverse();
        }
        catch (error) {
            // console.log('Error fetching OPP messages:', error);
            return [];
        }
    }

    /**
     * Extracts all OPPAssertions from a single OPPMessage event, attaching event metadata to each.
     */
    private async extractAssertionsFromEvent(event: any): Promise<OPPAssertion[]> {
        const header = event.args.header;
        const payloadAssertions = event.args.payload.assertions;

        const timestamp = header.timestamp ? Number(header.timestamp) : null;
        const from = event.address || null;
        const txHash = event.transactionHash;
        const chain = 'ETH';

        const assertionList: OPPAssertion[] = [];

        if (payloadAssertions && payloadAssertions.length > 0) {
            for (let a of payloadAssertions) {
                const assertionType = a[0] as number;
                const assertionData = a[1] as string;


                let type: OPPAssertion["type"] = "unknown";
                let data: any = {};
                let to: string | null = null;

                const decoder = this.decoders[assertionType];

                if (decoder) {
                    try {
                        const decoded = decoder(assertionData);
                        type = decoded.type;
                        data = decoded.data;
                        to = decoded.to ?? null;
                    } catch (e: any) {
                        // catch a bad assertion so it doesn't fail the entire function
                        type = "unknown";
                        data = {
                            decodeError: e?.message ?? String(e),
                            assertionType,
                            rawHex: assertionData,
                        };
                    }
                } else {
                    // Unknown assertion type
                    data = { assertionType, rawHex: assertionData };
                }

                assertionList.push({
                    type,
                    data,
                    chain,
                    timestamp,
                    from,
                    to,
                    txHash,
                    raw: event,
                });
            }
        }
        return assertionList;
    }
    


    private decode2001(assertionData: string): DecoderResult {
        const hex = this.strip0x(assertionData);

        let cursor = 0;
        const r1 = this.readAddress(hex, cursor); cursor = r1.next;
        const r2 = this.readAddress(hex, cursor); cursor = r2.next;
        const r3 = this.readBytes32(hex, cursor); cursor = r3.next;
        const r4 = this.readUint256LE(hex, cursor); cursor = r4.next;
        const r5 = this.readUint64LE(hex, cursor); cursor = r5.next;

        const data = {
            actor: r1.addr,
            owner: r2.addr,
            bondLevelId: r3.value,
            tokenId: r4.value,
            bondedAt: r5.value,
        };

        return { type: "bonded_actor", data, to: r1.addr };
    }
    private decode2002(assertionData: string): DecoderResult {
        const hex = this.strip0x(assertionData);

        let cursor = 0;
        const r1 = this.readAddress(hex, cursor); cursor = r1.next;
        const r2 = this.readBytes32(hex, cursor); cursor = r2.next;
        const r3 = this.readUint256LE(hex, cursor); cursor = r3.next;
        const r4 = this.readUint64LE(hex, cursor); cursor = r4.next;

        const data = {
            actor: r1.addr,
            bondLevelId: r2.value,
            tokenId: r3.value,
            unbondedAt: r4.value,
        };

        return { type: "unbonded_actor", data, to: r1.addr };
    }


    private decodeStake3001(assertionData: string): DecoderResult {
        const { addr, nums } = this.decodeAddrPlusU256(assertionData, 3);
        const [principal, shares, indexAtMint] = nums;

        return {
            type: "stake",
            data: { 
                staker: addr, 
                principal: ethers.utils.formatUnits(principal, 18),
                shares: ethers.utils.formatUnits(shares, 18),
                indexAtMint 
            },
            to: addr,
        };
    }
    private decodeUnstake3002(assertionData: string): DecoderResult {
        const { addr, nums } = this.decodeAddrPlusU256(assertionData, 4);
        const [unstakeAmount, shares, indexAtBurn, tokenId] = nums;

        return {
            type: "unstake",
            data: { 
                unstaker: addr, 
                unstakeAmount: ethers.utils.formatUnits(unstakeAmount, 18),
                shares, 
                indexAtBurn, 
                tokenId 
            },
            to: addr,
        };
    }
    private decodeLiqPretoken3004(assertionData: string): DecoderResult {
        const { addr, nums } = this.decodeAddrPlusU256(assertionData, 3);
        const [principal, shares, indexAtMint] = nums;

        return {
            type: "liq_pretoken_purchase",
            data: { 
                purchaser: addr, 
                principal: ethers.utils.formatUnits(principal, 18),
                shares: ethers.utils.formatUnits(shares, 18),
                indexAtMint 
            },
            to: addr,
        };
    }
    private decodePretoken3005(assertionData: string): DecoderResult {
        const { addr, nums } = this.decodeAddrPlusU256(assertionData, 3);
        const [ethInWei, usdValue, pretokensOut] = nums;

        return {
            type: "pretoken_purchase",
            data: {
                buyer: addr,
                ethInWei,
                ethIn: ethers.utils.formatUnits(ethInWei, 18),
                usdValue: ethers.utils.formatUnits(usdValue, 18),
                pretokensOut: ethers.utils.formatUnits(pretokensOut, 18),
            },
            to: addr,
        };
    }
    private decodeYieldPretoken3006(assertionData: string): DecoderResult {
        const { addr, nums } = this.decodeAddrPlusU256(assertionData, 2);
        const [principal, indexAtMint] = nums;

        return {
            type: "yield_pretoken_purchase",
            data: { purchaser: addr, principal, indexAtMint },
            to: addr,
        };
    }




    /**
     * 
     * @param hex string to check
     * @returns the passed string, without a prefixed 0x if it existed
     */
    private strip0x(hex: string): string {
        return hex.startsWith("0x") ? hex.slice(2) : hex;
    }

    private decodeAddrPlusU256(assertionData: string, count: number): { addr: string; nums: string[] } {
        const hex = this.strip0x(assertionData);
        let cursor = 0;

        const { addr, next } = this.readAddress(hex, cursor);
        cursor = next;

        const nums: string[] = [];
        for (let i = 0; i < count; i++) {
            const r = this.readUint256LE(hex, cursor);
            nums.push(r.value);
            cursor = r.next;
        }
        return { addr, nums };
    }

    private uint256FromLE(hexNo0x: string): ethers.BigNumber {
        if (hexNo0x.length !== 64) {
            throw new Error(`uint256 LE must be 32 bytes (64 hex chars), got ${hexNo0x.length}`);
        }
        const bytes = ethers.utils.arrayify("0x" + hexNo0x);
        const reversed = Uint8Array.from(bytes).reverse(); // little -> big endian
        return ethers.BigNumber.from(ethers.utils.hexlify(reversed));
    }


    /**
     * Parses "0x14 + <addr>" and returns addr + next cursor (in hex chars, excluding 0x)
     * @param payload full payload
     * @param cursor place to start
     * @returns string
     */
    private readAddress(payload: string, cursor: number): { addr: string; next: number } {
        const lenByteHex = payload.slice(cursor, cursor + 2);
        const len = parseInt(lenByteHex, 16);
        if (!Number.isFinite(len) || len <= 0) throw new Error(`Bad address len byte: ${lenByteHex}`);
        const start = cursor + 2;
        const end = start + len * 2;
        const addrHex = payload.slice(start, end);
        if (addrHex.length !== len * 2) throw new Error(`Truncated address bytes`);
        return { addr: ethers.utils.getAddress("0x" + addrHex), next: end };
    }

    /**
     * Reads 32-byte uint256 little-endian from payload at cursor (hex chars, excluding 0x)
     * @param payload 
     * @param cursor 
     * @returns 
     */
    private readUint256LE(payload: string, cursor: number): { value: string; next: number } {
        const word = payload.slice(cursor, cursor + 64);
        if (word.length !== 64) throw new Error(`Truncated uint256 at ${cursor}`);
        return { value: this.uint256FromLE(word).toString(), next: cursor + 64 };
    }

    /**
     * Reads 8-byte uint64 little-endian from payload at cursor (hex chars, excluding 0x)
     * @param payload 
     * @param cursor 
     * @returns 
     */
    private readUint64LE(payload: string, cursor: number): { value: number; next: number } {
        const word = payload.slice(cursor, cursor + 16);
        if (word.length !== 16) throw new Error(`Truncated uint64 at ${cursor}`);
        const bytes = ethers.utils.arrayify("0x" + word);
        const reversed = Uint8Array.from(bytes).reverse();
        const bn = ethers.BigNumber.from(ethers.utils.hexlify(reversed));
        return { value: bn.toNumber(), next: cursor + 16 };
    }

    /**
     * Reads bytes32 from payload at cursor (hex chars, excluding 0x)
     * @param payload 
     * @param cursor 
     * @returns 
     */
    private readBytes32(payload: string, cursor: number): { value: string; next: number } {
        const word = payload.slice(cursor, cursor + 64);
        if (word.length !== 64) throw new Error(`Truncated bytes32 at ${cursor}`);
        return { value: "0x" + word, next: cursor + 64 };
    }


}


type DecoderResult = { type: OPPAssertion["type"]; data: any; to?: string | null };
type Decoder = (assertionData: string) => DecoderResult;