import ts from "typescript";

import { ExpressionFactory } from "../../factories/ExpressionFactory";
import { IdentifierFactory } from "../../factories/IdentifierFactory";
import { MetadataCollection } from "../../factories/MetadataCollection";
import { NumericRangeFactory } from "../../factories/NumericRangeFactory";
import { ProtobufFactory } from "../../factories/ProtobufFactory";
import { StatementFactory } from "../../factories/StatementFactory";
import { TypeFactory } from "../../factories/TypeFactory";

import { Metadata } from "../../schemas/metadata/Metadata";
import { MetadataArray } from "../../schemas/metadata/MetadataArray";
import { MetadataAtomic } from "../../schemas/metadata/MetadataAtomic";
import { MetadataObject } from "../../schemas/metadata/MetadataObject";
import { MetadataProperty } from "../../schemas/metadata/MetadataProperty";

import { IProject } from "../../transformers/IProject";

import { ProtobufAtomic } from "../../typings/ProtobufAtomic";

import { FeatureProgrammer } from "../FeatureProgrammer";
import { IsProgrammer } from "../IsProgrammer";
import { FunctionImporter } from "../helpers/FunctionImporter";
import { ProtobufUtil } from "../helpers/ProtobufUtil";
import { ProtobufWire } from "../helpers/ProtobufWire";
import { UnionPredicator } from "../helpers/UnionPredicator";
import { decode_union_object } from "../internal/decode_union_object";

export namespace ProtobufEncodeProgrammer {
  export const decompose = (props: {
    project: IProject;
    modulo: ts.LeftHandSideExpression;
    importer: FunctionImporter;
    type: ts.Type;
    name: string | undefined;
  }): FeatureProgrammer.IDecomposed => {
    const collection: MetadataCollection = new MetadataCollection();
    const meta: Metadata = ProtobufFactory.metadata(props.modulo.getText())(
      props.project.checker,
      props.project.context,
    )(collection)(props.type);

    const callEncoder = (writer: string) => (factory: ts.NewExpression) =>
      StatementFactory.constant(
        writer,
        ts.factory.createCallExpression(
          ts.factory.createIdentifier("encoder"),
          undefined,
          [factory, ts.factory.createIdentifier("input")],
        ),
      );
    return {
      functions: {
        encoder: StatementFactory.constant(
          props.importer.useLocal("encoder"),
          write_encoder(props.project)(props.importer)(collection)(meta),
        ),
      },
      statements: [],
      arrow: ts.factory.createArrowFunction(
        undefined,
        undefined,
        [
          IdentifierFactory.parameter(
            "input",
            ts.factory.createTypeReferenceNode(
              props.name ??
                TypeFactory.getFullName(props.project.checker)(props.type),
            ),
          ),
        ],
        ts.factory.createTypeReferenceNode("Uint8Array"),
        undefined,
        ts.factory.createBlock(
          [
            callEncoder("sizer")(
              ts.factory.createNewExpression(
                props.importer.use("Sizer"),
                undefined,
                [],
              ),
            ),
            callEncoder("writer")(
              ts.factory.createNewExpression(
                props.importer.use("Writer"),
                undefined,
                [ts.factory.createIdentifier("sizer")],
              ),
            ),
            ts.factory.createReturnStatement(
              ts.factory.createCallExpression(
                IdentifierFactory.access(WRITER())("buffer"),
                undefined,
                undefined,
              ),
            ),
          ],
          true,
        ),
      ),
    };
  };

  export const write =
    (project: IProject) =>
    (modulo: ts.LeftHandSideExpression) =>
    (type: ts.Type, name?: string): ts.CallExpression => {
      const importer: FunctionImporter = new FunctionImporter(modulo.getText());
      const result: FeatureProgrammer.IDecomposed = decompose({
        project,
        modulo,
        importer,
        type,
        name,
      });
      return FeatureProgrammer.writeDecomposed({
        modulo,
        importer,
        result,
      });
    };

  const write_encoder =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (collection: MetadataCollection) =>
    (meta: Metadata): ts.ArrowFunction => {
      const functors = collection
        .objects()
        .filter((obj) => ProtobufUtil.isStaticObject(obj))
        .map((obj) =>
          StatementFactory.constant(
            `${PREFIX}o${obj.index}`,
            write_object_function(project)(importer)(
              ts.factory.createIdentifier("input"),
              obj,
              {
                source: "function",
                from: "object",
                tracable: false,
                postfix: "",
              },
            ),
          ),
        );
      const main = decode(project)(importer)(null)(
        ts.factory.createIdentifier("input"),
        meta,
        {
          source: "top",
          from: "top",
          tracable: false,
          postfix: "",
        },
      );
      return ts.factory.createArrowFunction(
        undefined,
        undefined,
        [
          IdentifierFactory.parameter("writer"),
          IdentifierFactory.parameter("input"),
        ],
        TypeFactory.keyword("any"),
        undefined,
        ts.factory.createBlock(
          [
            ...importer.declareUnions(),
            ...functors,
            ...IsProgrammer.write_function_statements(project)(importer)(
              collection,
            ),
            ...main.statements,
            ts.factory.createReturnStatement(
              ts.factory.createIdentifier("writer"),
            ),
          ],
          true,
        ),
      );
    };

  const write_object_function =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (
      input: ts.Expression,
      obj: MetadataObject,
      explore: FeatureProgrammer.IExplore,
    ): ts.ArrowFunction => {
      let index: number = 1;
      const body: ts.Statement[] = obj.properties
        .map((p) => {
          const block = decode(project)(importer)(index)(
            IdentifierFactory.access(input)(p.key.getSoleLiteral()!),
            p.value,
            explore,
          );
          index += ProtobufUtil.size(p.value);
          return [
            ts.factory.createExpressionStatement(
              ts.factory.createIdentifier(
                `// property "${p.key.getSoleLiteral()!}"`,
              ),
            ),
            ...block.statements,
          ];
        })
        .flat();

      return ts.factory.createArrowFunction(
        undefined,
        undefined,
        [IdentifierFactory.parameter("input")],
        TypeFactory.keyword("any"),
        undefined,
        ts.factory.createBlock(body, true),
      );
    };

  /* -----------------------------------------------------------
        DECODERS
    ----------------------------------------------------------- */
  const decode =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (index: number | null) =>
    (
      input: ts.Expression,
      meta: Metadata,
      explore: FeatureProgrammer.IExplore,
    ): ts.Block => {
      const wrapper: (block: ts.Block) => ts.Block =
        meta.isRequired() && meta.nullable === false
          ? (block) => block
          : meta.isRequired() === false && meta.nullable === true
            ? (block) =>
                ts.factory.createBlock(
                  [
                    ts.factory.createIfStatement(
                      ts.factory.createLogicalAnd(
                        ts.factory.createStrictInequality(
                          ts.factory.createIdentifier("undefined"),
                          input,
                        ),
                        ts.factory.createStrictInequality(
                          ts.factory.createNull(),
                          input,
                        ),
                      ),
                      block,
                    ),
                  ],
                  true,
                )
            : meta.isRequired() === false
              ? (block) =>
                  ts.factory.createBlock(
                    [
                      ts.factory.createIfStatement(
                        ts.factory.createStrictInequality(
                          ts.factory.createIdentifier("undefined"),
                          input,
                        ),
                        block,
                      ),
                    ],
                    true,
                  )
              : (block) =>
                  ts.factory.createBlock(
                    [
                      ts.factory.createIfStatement(
                        ts.factory.createStrictInequality(
                          ts.factory.createNull(),
                          input,
                        ),
                        block,
                      ),
                    ],
                    true,
                  );

      // STARTS FROM ATOMIC TYPES
      const unions: IUnion[] = [];
      const numbers = ProtobufUtil.getNumbers(meta);
      const bigints = ProtobufUtil.getBigints(meta);

      for (const atom of ProtobufUtil.getAtomics(meta))
        if (atom === "bool")
          unions.push({
            type: "bool",
            is: () =>
              ts.factory.createStrictEquality(
                ts.factory.createStringLiteral("boolean"),
                ts.factory.createTypeOfExpression(input),
              ),
            value: (index) => decode_bool(index)(input),
          });
        else if (
          atom === "int32" ||
          atom === "uint32" ||
          atom === "float" ||
          atom === "double"
        )
          unions.push(decode_number(numbers)(atom)(input));
        else if (atom === "int64" || atom === "uint64")
          if (numbers.some((n) => n === atom))
            unions.push(decode_number(numbers)(atom)(input));
          else unions.push(decode_bigint(bigints)(atom)(input));
        else if (atom === "string")
          unions.push({
            type: "string",
            is: () =>
              ts.factory.createStrictEquality(
                ts.factory.createStringLiteral("string"),
                ts.factory.createTypeOfExpression(input),
              ),
            value: (index) => decode_bytes("string")(index!)(input),
          });

      // CONSIDER BYTES
      if (meta.natives.length)
        unions.push({
          type: "bytes",
          is: () => ExpressionFactory.isInstanceOf("Uint8Array")(input),
          value: (index) => decode_bytes("bytes")(index!)(input),
        });

      // CONSIDER ARRAYS
      if (meta.arrays.length)
        unions.push({
          type: "array",
          is: () => ExpressionFactory.isArray(input),
          value: (index) =>
            decode_array(project)(importer)(index!)(input, meta.arrays[0]!, {
              ...explore,
              from: "array",
            }),
        });

      // CONSIDER MAPS
      if (meta.maps.length)
        unions.push({
          type: "map",
          is: () => ExpressionFactory.isInstanceOf("Map")(input),
          value: (index) =>
            decode_map(project)(importer)(index!)(input, meta.maps[0]!, {
              ...explore,
              from: "array",
            }),
        });

      // CONSIDER OBJECTS
      if (meta.objects.length)
        unions.push({
          type: "object",
          is: () =>
            ExpressionFactory.isObject({
              checkNull: true,
              checkArray: false,
            })(input),
          value: (index) =>
            explore_objects(project)(importer)(0)(index)(input, meta.objects, {
              ...explore,
              from: "object",
            }),
        });

      // RETURNS
      if (unions.length === 1) return wrapper(unions[0]!.value(index));
      else
        return wrapper(iterate(importer)(index)(unions)(meta.getName())(input));
    };

  const iterate =
    (importer: FunctionImporter) =>
    (index: number | null) =>
    (unions: IUnion[]) =>
    (expected: string) =>
    (input: ts.Expression) =>
      ts.factory.createBlock(
        [
          unions
            .map((u, i) =>
              ts.factory.createIfStatement(
                u.is(),
                u.value(index ? index + i : null),
                i === unions.length - 1
                  ? create_throw_error(importer)(expected)(input)
                  : undefined,
              ),
            )
            .reverse()
            .reduce((a, b) =>
              ts.factory.createIfStatement(b.expression, b.thenStatement, a),
            ),
        ],
        true,
      );

  const decode_map =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (index: number) =>
    (
      input: ts.Expression,
      map: Metadata.Entry,
      explore: FeatureProgrammer.IExplore,
    ): ts.Block => {
      const each: ts.Statement[] = [
        ts.factory.createExpressionStatement(
          decode_tag(ProtobufWire.LEN)(index),
        ),
        ts.factory.createExpressionStatement(
          ts.factory.createCallExpression(
            IdentifierFactory.access(WRITER())("fork"),
            undefined,
            undefined,
          ),
        ),
        ...decode(project)(importer)(1)(
          ts.factory.createIdentifier("key"),
          map.key,
          explore,
        ).statements,
        ...decode(project)(importer)(2)(
          ts.factory.createIdentifier("value"),
          map.value,
          explore,
        ).statements,
        ts.factory.createExpressionStatement(
          ts.factory.createCallExpression(
            IdentifierFactory.access(WRITER())("ldelim"),
            undefined,
            undefined,
          ),
        ),
      ];
      return ts.factory.createBlock(
        [
          ts.factory.createForOfStatement(
            undefined,
            StatementFactory.entry("key")("value"),
            input,
            ts.factory.createBlock(each),
          ),
        ],
        true,
      );
    };

  const decode_object =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (index: number | null) =>
    (
      input: ts.Expression,
      object: MetadataObject,
      explore: FeatureProgrammer.IExplore,
    ): ts.Block => {
      const top: MetadataProperty = object.properties[0]!;
      if (top.key.isSoleLiteral() === false)
        return decode_map(project)(importer)(index!)(
          ts.factory.createCallExpression(
            ts.factory.createIdentifier("Object.entries"),
            [],
            [input],
          ),
          MetadataProperty.create({
            ...top,
            key: (() => {
              const key: Metadata = Metadata.initialize();
              key.atomics.push(
                MetadataAtomic.create({
                  type: "string",
                  tags: [],
                }),
              );
              return key;
            })(),
          }),
          explore,
        );
      return ts.factory.createBlock(
        [
          ts.factory.createIdentifier(
            `//${index !== null ? ` ${index} -> ` : ""}${object.name}`,
          ),
          ...(index !== null
            ? [
                decode_tag(ProtobufWire.LEN)(index),
                ts.factory.createCallExpression(
                  IdentifierFactory.access(WRITER())("fork"),
                  undefined,
                  undefined,
                ),
              ]
            : []),
          ts.factory.createCallExpression(
            ts.factory.createIdentifier(
              importer.useLocal(`${PREFIX}o${object.index}`),
            ),
            [],
            [input],
          ),
          ...(index !== null
            ? [
                ts.factory.createCallExpression(
                  IdentifierFactory.access(WRITER())("ldelim"),
                  undefined,
                  undefined,
                ),
              ]
            : []),
        ].map((expr) => ts.factory.createExpressionStatement(expr)),
        true,
      );
    };

  const decode_array =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (index: number) =>
    (
      input: ts.Expression,
      array: MetadataArray,
      explore: FeatureProgrammer.IExplore,
    ): ts.Block => {
      const wire = get_standalone_wire(array.type.value);
      const forLoop = (index: number | null) =>
        ts.factory.createForOfStatement(
          undefined,
          ts.factory.createVariableDeclarationList(
            [ts.factory.createVariableDeclaration("elem")],
            ts.NodeFlags.Const,
          ),
          input,
          decode(project)(importer)(index)(
            ts.factory.createIdentifier("elem"),
            array.type.value,
            explore,
          ),
        );
      const length = (block: ts.Block) =>
        ts.factory.createBlock(
          [
            ts.factory.createIfStatement(
              ts.factory.createStrictInequality(
                ExpressionFactory.number(0),
                IdentifierFactory.access(input)("length"),
              ),
              block,
            ),
          ],
          true,
        );

      if (wire === ProtobufWire.LEN)
        return length(ts.factory.createBlock([forLoop(index)], true));
      return length(
        ts.factory.createBlock(
          [
            ts.factory.createExpressionStatement(
              decode_tag(ProtobufWire.LEN)(index),
            ),
            ts.factory.createExpressionStatement(
              ts.factory.createCallExpression(
                IdentifierFactory.access(WRITER())("fork"),
                undefined,
                undefined,
              ),
            ),
            forLoop(null),
            ts.factory.createExpressionStatement(
              ts.factory.createCallExpression(
                IdentifierFactory.access(WRITER())("ldelim"),
                undefined,
                undefined,
              ),
            ),
          ],
          true,
        ),
      );
    };

  const decode_bool = (index: number | null) => (input: ts.Expression) =>
    ts.factory.createBlock(
      [
        ...(index !== null ? [decode_tag(ProtobufWire.VARIANT)(index)] : []),
        ts.factory.createCallExpression(
          IdentifierFactory.access(WRITER())("bool"),
          undefined,
          [input],
        ),
      ].map((exp) => ts.factory.createExpressionStatement(exp)),
      true,
    );

  const decode_number =
    (candidates: ProtobufAtomic.Numeric[]) =>
    (type: ProtobufAtomic.Numeric) =>
    (input: ts.Expression): IUnion => ({
      type,
      is: () =>
        candidates.length === 1
          ? ts.factory.createStrictEquality(
              ts.factory.createStringLiteral("number"),
              ts.factory.createTypeOfExpression(input),
            )
          : ts.factory.createLogicalAnd(
              ts.factory.createStrictEquality(
                ts.factory.createStringLiteral("number"),
                ts.factory.createTypeOfExpression(input),
              ),
              NumericRangeFactory.number(type)(input),
            ),
      value: (index) =>
        ts.factory.createBlock(
          [
            ...(index !== null
              ? [decode_tag(get_numeric_wire(type))(index)]
              : []),
            ts.factory.createCallExpression(
              IdentifierFactory.access(WRITER())(type),
              undefined,
              [input],
            ),
          ].map((exp) => ts.factory.createExpressionStatement(exp)),
          true,
        ),
    });

  const decode_bigint =
    (candidates: ProtobufAtomic.BigNumeric[]) =>
    (type: ProtobufAtomic.BigNumeric) =>
    (input: ts.Expression): IUnion => ({
      type,
      is: () =>
        candidates.length === 1
          ? ts.factory.createStrictEquality(
              ts.factory.createStringLiteral("bigint"),
              ts.factory.createTypeOfExpression(input),
            )
          : ts.factory.createLogicalAnd(
              ts.factory.createStrictEquality(
                ts.factory.createStringLiteral("bigint"),
                ts.factory.createTypeOfExpression(input),
              ),
              NumericRangeFactory.bigint(type)(input),
            ),
      value: (index) =>
        ts.factory.createBlock(
          [
            ...(index !== null
              ? [decode_tag(ProtobufWire.VARIANT)(index)]
              : []),
            ts.factory.createCallExpression(
              IdentifierFactory.access(WRITER())(type),
              undefined,
              [input],
            ),
          ].map((exp) => ts.factory.createExpressionStatement(exp)),
          true,
        ),
    });

  const decode_bytes =
    (method: "bytes" | "string") =>
    (index: number) =>
    (input: ts.Expression): ts.Block =>
      ts.factory.createBlock(
        [
          decode_tag(ProtobufWire.LEN)(index),
          ts.factory.createCallExpression(
            IdentifierFactory.access(WRITER())(method),
            undefined,
            [input],
          ),
        ].map((expr) => ts.factory.createExpressionStatement(expr)),
        true,
      );

  const decode_tag =
    (wire: ProtobufWire) =>
    (index: number): ts.CallExpression =>
      ts.factory.createCallExpression(
        IdentifierFactory.access(WRITER())("uint32"),
        undefined,
        [ExpressionFactory.number((index << 3) | wire)],
      );

  const get_standalone_wire = (meta: Metadata): ProtobufWire => {
    if (
      meta.arrays.length ||
      meta.objects.length ||
      meta.maps.length ||
      meta.natives.length
    )
      return ProtobufWire.LEN;

    const v = ProtobufUtil.getAtomics(meta)[0]!;
    if (v === "string") return ProtobufWire.LEN;
    else if (
      v === "bool" ||
      v === "int32" ||
      v === "uint32" ||
      v === "int64" ||
      v === "uint64"
    )
      return ProtobufWire.VARIANT;
    else if (v === "float") return ProtobufWire.I32;
    return ProtobufWire.I64;
  };

  const get_numeric_wire = (type: ProtobufAtomic.Numeric) =>
    type === "double"
      ? ProtobufWire.I64
      : type === "float"
        ? ProtobufWire.I32
        : ProtobufWire.VARIANT;

  /* -----------------------------------------------------------
        EXPLORERS
    ----------------------------------------------------------- */
  const explore_objects =
    (project: IProject) =>
    (importer: FunctionImporter) =>
    (level: number) =>
    (index: number | null) =>
    (
      input: ts.Expression,
      targets: MetadataObject[],
      explore: FeatureProgrammer.IExplore,
      indexes?: Map<MetadataObject, number>,
    ): ts.Block => {
      if (targets.length === 1)
        return decode_object(project)(importer)(
          indexes ? indexes.get(targets[0]!)! : index,
        )(input, targets[0]!, explore);

      const expected: string = `(${targets.map((t) => t.name).join(" | ")})`;

      // POSSIBLE TO SPECIALIZE?
      const specList = UnionPredicator.object(targets);
      indexes ??= new Map(targets.map((t, i) => [t, index! + i]));

      if (specList.length === 0) {
        const condition: ts.Expression = decode_union_object(
          IsProgrammer.decode_object(project)(importer),
        )((i, o, e) =>
          ExpressionFactory.selfCall(
            decode_object(project)(importer)(indexes!.get(o)!)(i, o, e),
          ),
        )((expr) => expr)((value, expected) =>
          create_throw_error(importer)(expected)(value),
        )(input, targets, explore);
        return StatementFactory.block(condition);
      }
      const remained: MetadataObject[] = targets.filter(
        (t) => specList.find((s) => s.object === t) === undefined,
      );

      // DO SPECIALIZE
      const condition: ts.IfStatement = specList
        .filter((spec) => spec.property.key.getSoleLiteral() !== null)
        .map((spec, i, array) => {
          const key: string = spec.property.key.getSoleLiteral()!;
          const accessor: ts.Expression = IdentifierFactory.access(input)(key);
          const pred: ts.Expression = spec.neighbour
            ? IsProgrammer.decode(project)(importer)(
                accessor,
                spec.property.value,
                {
                  ...explore,
                  tracable: false,
                  postfix: IdentifierFactory.postfix(key),
                },
              )
            : ExpressionFactory.isRequired(accessor);
          return ts.factory.createIfStatement(
            pred,
            ts.factory.createExpressionStatement(
              ExpressionFactory.selfCall(
                decode_object(project)(importer)(indexes!.get(spec.object)!)(
                  input,
                  spec.object,
                  explore,
                ),
              ),
            ),
            i === array.length - 1
              ? remained.length
                ? ts.factory.createExpressionStatement(
                    ExpressionFactory.selfCall(
                      explore_objects(project)(importer)(level + 1)(index)(
                        input,
                        remained,
                        explore,
                        indexes!,
                      ),
                    ),
                  )
                : create_throw_error(importer)(expected)(input)
              : undefined,
          );
        })
        .reverse()
        .reduce((a, b) =>
          ts.factory.createIfStatement(b.expression, b.thenStatement, a),
        );

      // RETURNS WITH CONDITIONS
      return ts.factory.createBlock([condition], true);
    };

  /* -----------------------------------------------------------
        CONFIGURATIONS
    ----------------------------------------------------------- */
  const PREFIX = "$pe";

  const create_throw_error =
    (importer: FunctionImporter) =>
    (expected: string) =>
    (value: ts.Expression) =>
      ts.factory.createExpressionStatement(
        ts.factory.createCallExpression(
          importer.use("throws"),
          [],
          [
            ts.factory.createObjectLiteralExpression(
              [
                ts.factory.createPropertyAssignment(
                  "expected",
                  ts.factory.createStringLiteral(expected),
                ),
                ts.factory.createPropertyAssignment("value", value),
              ],
              true,
            ),
          ],
        ),
      );
}

const WRITER = () => ts.factory.createIdentifier("writer");

interface IUnion {
  type: string;
  is: () => ts.Expression;
  value: (index: number | null) => ts.Block;
}
