// Copyright 2017-2025 @pezkuwi/types authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { AnyU8a, Registry } from '@pezkuwi/types-codec/types';
import type { BN } from '@pezkuwi/util';
import type { IExtrinsicEra, INumber } from '../types/index.js';

import { Enum, Raw, Tuple, U64 } from '@pezkuwi/types-codec';
import { bnToBn, formatNumber, hexToU8a, isHex, isObject, isU8a, u8aToBn, u8aToU8a } from '@pezkuwi/util';

import { IMMORTAL_ERA } from './constants.js';

type MortalEraValue = [INumber, INumber];

interface MortalMethod {
  current: number;
  period: number;
}

interface MortalEnumDef {
  MortalEra: string;
}

interface ImmortalEnumDef {
  ImmortalEra: string;
}

function getTrailingZeros (period: number): number {
  const binary = period.toString(2);
  let index = 0;

  while (binary[binary.length - 1 - index] === '0') {
    index++;
  }

  return index;
}

/** @internal */
function decodeMortalEra (registry: Registry, value?: MortalMethod | Uint8Array | number[] | string): MortalEraValue {
  if (isU8a(value) || isHex(value) || Array.isArray(value)) {
    return decodeMortalU8a(registry, u8aToU8a(value));
  } else if (!value) {
    return [new U64(registry), new U64(registry)];
  } else if (isObject(value)) {
    return decodeMortalObject(registry, value);
  }

  throw new Error('Invalid data passed to Mortal era');
}

/** @internal */
function decodeMortalObject (registry: Registry, value: MortalMethod): MortalEraValue {
  const { current, period } = value;
  let calPeriod = Math.pow(2, Math.ceil(Math.log2(period)));

  calPeriod = Math.min(Math.max(calPeriod, 4), 1 << 16);

  const phase = current % calPeriod;
  const quantizeFactor = Math.max(calPeriod >> 12, 1);
  const quantizedPhase = phase / quantizeFactor * quantizeFactor;

  return [new U64(registry, calPeriod), new U64(registry, quantizedPhase)];
}

/** @internal */
function decodeMortalU8a (registry: Registry, value: Uint8Array): MortalEraValue {
  if (value.length === 0) {
    return [new U64(registry), new U64(registry)];
  }

  const first = u8aToBn(value.subarray(0, 1)).toNumber();
  const second = u8aToBn(value.subarray(1, 2)).toNumber();
  const encoded: number = first + (second << 8);
  const period = 2 << (encoded % (1 << 4));
  const quantizeFactor = Math.max(period >> 12, 1);
  const phase = (encoded >> 4) * quantizeFactor;

  if (period < 4 || phase >= period) {
    throw new Error('Invalid data passed to Mortal era');
  }

  return [new U64(registry, period), new U64(registry, phase)];
}

/** @internal */
// eslint-disable-next-line @typescript-eslint/ban-types
function decodeExtrinsicEra (value: IExtrinsicEra | MortalMethod | MortalEnumDef | ImmortalEnumDef | Uint8Array | string = new Uint8Array()): Uint8Array | Object | undefined {
  if (isU8a(value)) {
    return (!value.length || value[0] === 0)
      ? new Uint8Array([0])
      : new Uint8Array([1, value[0], value[1]]);
  } else if (!value) {
    return new Uint8Array([0]);
  } else if (value instanceof GenericExtrinsicEra) {
    return decodeExtrinsicEra(value.toU8a());
  } else if (isHex(value)) {
    return decodeExtrinsicEra(hexToU8a(value));
  } else if (isObject(value)) {
    const entries = Object.entries(value as MortalEnumDef).map(([k, v]): [string, any] => [k.toLowerCase(), v]);
    const mortal = entries.find(([k]) => k.toLowerCase() === 'mortalera');
    const immortal = entries.find(([k]) => k.toLowerCase() === 'immortalera');

    // this is to de-serialize from JSON
    return mortal
      ? { MortalEra: mortal[1] as string }
      : immortal
        ? { ImmortalEra: immortal[1] as string }
        : { MortalEra: value };
  }

  throw new Error('Invalid data passed to Era');
}

/**
 * @name ImmortalEra
 * @description
 * The ImmortalEra for an extrinsic
 */
export class ImmortalEra extends Raw {
  constructor (registry: Registry, _value?: AnyU8a) {
    // For immortals, we always provide the known value (i.e. treated as a
    // constant no matter how it is constructed - it is a fixed structure)
    super(registry, IMMORTAL_ERA);
  }
}

/**
 * @name MortalEra
 * @description
 * The MortalEra for an extrinsic, indicating period and phase
 */
export class MortalEra extends Tuple {
  constructor (registry: Registry, value?: MortalMethod | Uint8Array | number[] | string) {
    super(registry, {
      period: U64,
      phase: U64
    }, decodeMortalEra(registry, value));
  }

  /**
   * @description Encoded length for mortals occupy 2 bytes, different from the actual Tuple since it is encoded. This is a shortcut fro `toU8a().length`
   */
  public override get encodedLength (): number {
    return 2 | 0;
  }

  /**
   * @description The period of this Mortal wraps as a [[U64]]
   */
  public get period (): INumber {
    return this[0] as INumber;
  }

  /**
   * @description The phase of this Mortal wraps as a [[U64]]
   */
  public get phase (): INumber {
    return this[1] as INumber;
  }

  /**
   * @description Converts the Object to to a human-friendly JSON, with additional fields, expansion and formatting of information
   */
  public override toHuman (): any {
    return {
      period: formatNumber(this.period),
      phase: formatNumber(this.phase)
    };
  }

  /**
   * @description Returns a JSON representation of the actual value
   */
  public override toJSON (): any {
    return this.toHex();
  }

  /**
   * @description Encodes the value as a Uint8Array as per the parity-codec specifications
   * @param isBare true when the value has none of the type-specific prefixes (internal)
   * Period and phase are encoded:
   *   - The period of validity from the block hash found in the signing material.
   *   - The phase in the period that this transaction's lifetime begins (and, importantly,
   *     implies which block hash is included in the signature material). If the `period` is
   *     greater than 1 << 12, then it will be a factor of the times greater than 1<<12 that
   *     `period` is.
   */
  public override toU8a (_isBare?: boolean): Uint8Array {
    const period = this.period.toNumber();
    const encoded = Math.min(
      15,
      Math.max(1, getTrailingZeros(period) - 1)
    ) + (
      (
        this.phase.toNumber() / Math.max(period >> 12, 1)
      ) << 4
    );

    return new Uint8Array([
      encoded & 0xff,
      encoded >> 8
    ]);
  }

  /**
   * @description Get the block number of the start of the era whose properties this object describes that `current` belongs to.
   */
  public birth (current: BN | bigint | number | string): number {
    const phase = this.phase.toNumber();
    const period = this.period.toNumber();

    // FIXME No toNumber() here
    return (
      ~~(
        (
          Math.max(bnToBn(current).toNumber(), phase) - phase
        ) / period
      ) * period
    ) + phase;
  }

  /**
   * @description Get the block number of the first block at which the era has ended.
   */
  public death (current: BN | bigint | number | string): number {
    // FIXME No toNumber() here
    return this.birth(current) + this.period.toNumber();
  }
}

/**
 * @name GenericExtrinsicEra
 * @description
 * The era for an extrinsic, indicating either a mortal or immortal extrinsic
 */
export class GenericExtrinsicEra extends Enum implements IExtrinsicEra {
  constructor (registry: Registry, value?: unknown) {
    super(registry, {
      ImmortalEra,
      MortalEra
    }, decodeExtrinsicEra(value as string));
  }

  /**
   * @description Override the encoded length method
   */
  public override get encodedLength (): number {
    return this.isImmortalEra
      ? this.asImmortalEra.encodedLength
      : this.asMortalEra.encodedLength;
  }

  /**
   * @description Returns the item as a [[ImmortalEra]]
   */
  public get asImmortalEra (): ImmortalEra {
    if (!this.isImmortalEra) {
      throw new Error(`Cannot convert '${this.type}' via asImmortalEra`);
    }

    return this.inner as ImmortalEra;
  }

  /**
   * @description Returns the item as a [[MortalEra]]
   */
  public get asMortalEra (): MortalEra {
    if (!this.isMortalEra) {
      throw new Error(`Cannot convert '${this.type}' via asMortalEra`);
    }

    return this.inner as MortalEra;
  }

  /**
   * @description `true` if Immortal
   */
  public get isImmortalEra (): boolean {
    return this.index === 0;
  }

  /**
   * @description `true` if Mortal
   */
  public get isMortalEra (): boolean {
    return this.index > 0;
  }

  /**
   * @description Encodes the value as a Uint8Array as per the parity-codec specifications
   * @param isBare true when the value has none of the type-specific prefixes (internal)
   */
  public override toU8a (isBare?: boolean): Uint8Array {
    return this.isMortalEra
      ? this.asMortalEra.toU8a(isBare)
      : this.asImmortalEra.toU8a(isBare);
  }
}
