/* eslint-disable vitest/valid-describe-callback */
import { ok, strictEqual } from "assert";
import { describe, it } from "vitest";
import { Enum, Interface, Model, Operation, Type } from "../../src/core/types.js";
import { expectDiagnostics, expectTypeEquals, mockFile, t } from "../../src/testing/index.js";
import { Tester } from "../tester.js";

describe("compiler: references", () => {
  function itCanReference({
    code,
    ref,
    resolveTarget,
  }: {
    code: string;
    ref: string;
    resolveTarget?: (target: any) => Type | undefined;
  }) {
    async function runTest(codeBefore: string, codeAfter: string) {
      const { RefContainer, target } = (await Tester.compile(
        t.code`
          ${codeBefore}
          model ${t.model("RefContainer")} { y: ${ref} }
          ${codeAfter}
        `,
      )) as any;
      const expectedTarget = resolveTarget ? resolveTarget(target) : target;
      expectTypeEquals(RefContainer.properties.get("y")!.type, expectedTarget);
    }
    it("reference before declaration", () => runTest("", code));
    it("reference after declaration", () => runTest(code, ""));
  }

  describe("model properties", () => {
    describe("simple property", () =>
      itCanReference({
        code: `
      model MyModel {
        @test("target") x: string;
      }`,
        ref: "MyModel.x",
      }));

    describe("member of alias of alias of model", () =>
      itCanReference({
        code: `
          model MyModel {
            @test("target") x: string;
          }
          alias Alias1  = MyModel;
          alias MyModelAlias  = Alias1;
        `,
        ref: "MyModelAlias.x",
      }));

    describe("spread property from model defined before", () =>
      itCanReference({
        code: `
          model Spreadable {
            y: string;
          }
    
          @test("target") model MyModel {
            x: string;
            ... Spreadable;
          }`,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property from model defined after", () =>
      itCanReference({
        code: `
          @test("target") model MyModel {
            x: string;
            ... Spreadable;
          }
          
          model Spreadable {
            y: string;
          }`,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property via alias", () =>
      itCanReference({
        code: `
          model Spreadable {
            y: string;
          }
    
          alias SpreadAlias = Spreadable;
    
          @test("target") model MyModel {
            x: string;
            ... SpreadAlias;
          }
        `,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property via alias of alias", () =>
      itCanReference({
        code: `
          model Spreadable {
            y: string;
          }
    
          alias SpreadAlias1 = Spreadable;
          alias SpreadAlias2 = SpreadAlias1;
          alias SpreadAlias3 = SpreadAlias2;
    
          @test("target") model MyModel {
            x: string;
            ... SpreadAlias3;
          }
        `,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property from a templated model", () =>
      itCanReference({
        code: `
          model Spreadable<T> {
            y: T;
          }
    
    
          @test("target") model MyModel {
            x: string;
            ... Spreadable<string>;
          }
        `,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property from a template parameter", () =>
      itCanReference({
        code: `
          model B {
            b: string;
          }
    
          @test("target") model Spreadable<T> {
            a: string;
            ...T;
          }
    
          alias Spread = Spreadable<B>;
        `,
        ref: "Spread.b",
        resolveTarget: (target: Model) => target.properties.get("b"),
      }));

    describe("property from `model is`", () =>
      itCanReference({
        code: `
          model Base {
            y: string;
          }
    
          @test("target") model MyModel is Base {
            x: string;
          }`,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("inherited property", () =>
      itCanReference({
        code: `
          // Here the base model property is what ends up being referenced.
          @test("target") model Base {
            y: string;
          }
    
          model MyModel extends Base {
            x: string;
          }`,
        ref: "MyModel.y",
        resolveTarget: (base: Model) => base.properties.get("y"),
      }));

    describe("property from template instance alias", () =>
      itCanReference({
        code: `
          @test("target") model Template<T> {
            y: T;
          }
    
          alias MyModel = Template<string>;
        `,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("spread property from template instance alias", () =>
      itCanReference({
        code: `
          model Base<T> {
            y: T
          }

          @test("target") model Template<T> {
            ...Base<T>
          }
    
          alias MyModel = Template<string>;
        `,
        ref: "MyModel.y",
        resolveTarget: (target: Model) => target.properties.get("y"),
      }));

    describe("sibling property", () => {
      it("can reference sibling property defined before", async () => {
        const { Foo } = await Tester.compile(t.code`
          model ${t.model("Foo")} {
            a: string;
            b: Foo.a;
          }
        `);

        expectTypeEquals(Foo.properties.get("b")!.type, Foo.properties.get("a")!);
      });

      it("can reference sibling property defined after", async () => {
        const { Foo } = await Tester.compile(t.code`
          model ${t.model("Foo")} {
            a: Foo.b;
            b: string;
          }
        `);

        strictEqual(Foo.properties.get("a")!.type, Foo.properties.get("b"));
      });
    });

    it("member reference via templated alias with default parameters", async () => {
      const { X } = await Tester.compile(t.code`
        model M<T = string> { prop: T; } 
        alias A<T = string> = M<T>;

        model ${t.model("X")} { y: A.prop; }
      `);

      const y = X.properties.get("y")!;
      strictEqual(y.type.kind, "ModelProperty");
      strictEqual(y.type.type.kind, "Scalar");
      strictEqual(y.type.type.name, "string");
    });

    it("member reference via templated alias with different alias defaults", async () => {
      const { X } = await Tester.compile(t.code`
          model M<T = string> { prop: T; }
          alias A<U = boolean> = M<U>;

          model ${t.model("X")} { y: A.prop; }
      `);

      const y = X.properties.get("y")!;
      strictEqual(y.type.kind, "ModelProperty");
      strictEqual(y.type.type.kind, "Scalar");
      strictEqual(y.type.type.name, "boolean");
    });

    it("member reference via alias-of-alias (templated, defaultable)", async () => {
      const { X } = await Tester.compile(t.code`
          model M<T> { prop: T; }

          alias A<T> = M<T>;
          alias B<T = boolean> = A<T>;

          model ${t.model("X")} { y: B.prop; }
      `);

      const y = X.properties.get("y")!;
      strictEqual(y.type.kind, "ModelProperty");
      strictEqual(y.type.type.kind, "Scalar");
      strictEqual(y.type.type.name, "boolean");
    });

    it("member reference via templated alias to model literal with default argument", async () => {
      const { Example } = await Tester.compile(t.code`
        alias A<T = string> = { t: T; };
        model ${t.model("Example")} { prop: A.t }
      `);

      const prop = Example.properties.get("prop")!;
      strictEqual(prop.type.kind, "ModelProperty");
      strictEqual(prop.type.type.kind, "Scalar");
      strictEqual(prop.type.type.name, "string");
    });

    it("reports an error when referencing an uninstantiated alias", async () => {
      const diagnostics = await Tester.diagnose(`
        alias A<T> = { t: T; };
        model Example { prop: A.t }
      `);

      expectDiagnostics(diagnostics, [
        {
          code: "invalid-template-args",
          message: "Template argument 'T' is required and not specified.",
        },
      ]);
    });
  });

  describe("enum members", () => {
    describe("simple enum member", () =>
      itCanReference({
        code: `
          enum MyEnum {
            @test("target") x, y, z
          }
        `,
        ref: "MyEnum.x",
      }));

    describe("spread member", () =>
      itCanReference({
        code: `
          enum Spreadable {
            x, y
          }
    
          @test("target") enum MyEnum {
            ... Spreadable,
            z
          }`,
        ref: "MyEnum.y",
        resolveTarget: (target: Enum) => target.members.get("y"),
      }));

    describe("alias enum, member", () =>
      itCanReference({
        code: `
          enum MyEnum {
            x, @test("target") y
          }
    
          alias MyEnumAlias = MyEnum;
        `,
        ref: "MyEnumAlias.y",
      }));

    describe("reference in namespace decorator", () => {
      it("can reference enum resolved in a namespace decorator", async () => {
        let taggedValue: Model | undefined;
        const { MyEnum } = await Tester.files({
          "collect.js": mockFile.js({
            $collect: (_: any, _t: any, value: any) => (taggedValue = value),
          }),
        }).import("./collect.js").compile(t.code`
            enum ${t.enum("MyEnum")} { a, b }

            model Template<T extends AorB> {
              t: T;
            }

            alias AorB = A | B;

            model A {
              type: MyEnum.a;
            }

            model B {
              type: MyEnum.b;
            }

            model My {
              type: MyEnum.b;
            }

            @collect(Template<My>)
            namespace Test { }
          `);

        ok(taggedValue);
        const tagType = taggedValue.properties.get("t")?.type;
        strictEqual(tagType?.kind, "Model" as const);
        strictEqual(tagType.properties.get("type")?.type.kind, "EnumMember" as const);
        strictEqual(tagType.properties.get("type")?.type, MyEnum.members.get("b"));
      });

      it("alias don't conflict", async () => {
        let taggedValue: Model | undefined;
        const { Foo } = await Tester.files({
          "collect.js": mockFile.js({
            $collect: (_: any, _t: any, value: any) => (taggedValue = value),
          }),
        }).import("./collect.js").compile(t.code`
            @collect(Foo.a)
            namespace MyService;

            interface Base<TResource> {}
            
            alias ViaAlias<T> = Base<T>;
            alias ViaAlias2 = ViaAlias<string>;
            
            interface MyInterface extends ViaAlias2 {}
            
            enum ${t.enum("Foo")} {
              a,
            }
          `);

        strictEqual(taggedValue, Foo.members.get("a"));
      });
    });
  });

  describe("union variants", () => {
    describe("simple variant", () =>
      itCanReference({
        code: `
          union MyUnion {
            @test("target") x: string;
          }
        `,
        ref: "MyUnion.x",
      }));

    describe("variant in alias union", () =>
      itCanReference({
        code: `
          union MyUnion {
            @test("target") x: string;
          }

          alias MyUnionAlias = MyUnion;
        `,
        ref: "MyUnionAlias.x",
      }));

    describe("variant in template union instance", () =>
      itCanReference({
        code: `
          union MyUnion<T> {
            @test("target") x: T;
          }

          alias MyUnionT = MyUnion<string>;

        `,
        ref: "MyUnionT.x",
      }));
  });

  describe("interface members", () => {
    describe("simple member", () =>
      itCanReference({
        code: `
          interface MyInterface {
            @test("target") operation(): void;
          }
        `,
        ref: "MyInterface.operation",
      }));

    describe("member of alias interface", () =>
      itCanReference({
        code: `
          interface MyInterface {
            @test("target") operation(): void;
          }
          alias MyInterfaceAlias  = MyInterface;
        `,
        ref: "MyInterfaceAlias.operation",
      }));

    describe("member of alias of alias of interface", () =>
      itCanReference({
        code: `
          interface MyInterface {
            @test("target") operation(): void;
          }
          alias MyInterfaceAlias1  = MyInterface;
          alias MyInterfaceAlias  = MyInterfaceAlias1;
        `,
        ref: "MyInterfaceAlias.operation",
      }));

    describe("member of templated interface instance", () =>
      itCanReference({
        code: `
          interface MyInterface<T> {
            @test("target") operation(): T;
          }
          alias MyInterfaceT  = MyInterface<string>;
        `,
        ref: "MyInterfaceT.operation",
      }));

    describe("member from `interface extends`", () =>
      itCanReference({
        code: `
          interface Base {
            operation(): void;
          }
    
          @test("target") interface MyInterface extends Base {
            x(): void;
          }
        `,
        ref: "MyInterface.operation",
        resolveTarget: (target: Interface) => target.operations.get("operation"),
      }));

    describe("reference sibling members", () => {
      let linkedValue: Operation | undefined;

      it("defined before", async () => {
        const { Foo } = await Tester.files({
          "test-link.js": mockFile.js({
            $testLink: (_: any, _t: any, value: Operation) => {
              linkedValue = value;
            },
          }),
        }).import("./test-link.js").compile(t.code`
            interface ${t.interface("Foo")} {
              one(): void;
              @testLink(Foo.one)
              two(): void;
            }
          `);

        strictEqual(linkedValue, Foo.operations.get("one"));
      });

      it("defined after", async () => {
        const { Foo } = await Tester.files({
          "test-link.js": mockFile.js({
            $testLink: (_: any, _t: any, value: Operation) => {
              linkedValue = value;
            },
          }),
        }).import("./test-link.js").compile(t.code`
            interface ${t.interface("Foo")} {
              @testLink(Foo.two) 
              one(): void;
              two(): void;
            }
          `);

        strictEqual(linkedValue, Foo.operations.get("two"));
      });
    });

    it("operation reference via templated alias with default parameters", async () => {
      const { example } = await Tester.compile(t.code`
          interface I<T = string> { o(): T; }
          alias A<T = string> = I<T>;

          op ${t.op("example")} is A.o;
      `);
      strictEqual(example.kind, "Operation");
      strictEqual(example.returnType.kind, "Scalar");
      strictEqual(example.returnType.name, "string");
    });

    it("operation reference via templated alias with different alias defaults", async () => {
      const { example } = await Tester.compile(t.code`
          interface I<T = string> { o(): T; }
          alias A<U = boolean> = I<U>;

          op ${t.op("example")} is A.o;
      `);

      strictEqual(example.kind, "Operation");
      strictEqual(example.returnType.kind, "Scalar");
      strictEqual(example.returnType.name, "boolean");
    });

    it("operation reference via alias-of-alias (templated, defaultable)", async () => {
      const { example } = await Tester.compile(t.code`
          interface I<T> { o(): T; }
          alias A<T> = I<T>;
          alias B<T = boolean> = A<T>;
          
          op ${t.op("example")} is B.o;
      `);

      strictEqual(example.kind, "Operation");
      strictEqual(example.returnType.kind, "Scalar");
      strictEqual(example.returnType.name, "boolean");
    });
  });

  it("throws proper diagnostics", async () => {
    const diagnostics = await Tester.diagnose(`
      model M { }
      interface I { }
      union U { }
      enum E { }

      model Test {
        m: M.x;
        i: I.x;
        u: U.x;
        e: E.x;
      }
    `);

    expectDiagnostics(diagnostics, [
      {
        code: "invalid-ref",
        message: `Model doesn't have member x`,
      },
      {
        code: "invalid-ref",
        message: `Interface doesn't have member x`,
      },
      {
        code: "invalid-ref",
        message: `Union doesn't have member x`,
      },
      {
        code: "invalid-ref",
        message: `Enum doesn't have member x`,
      },
    ]);
  });

  it("referencing alias that reference an invalid ref should emit diagnostic", async () => {
    const diagnostics = await Tester.diagnose(`
      alias A = NotDefined;
      alias B = A;
    `);

    expectDiagnostics(diagnostics, [
      {
        code: "invalid-ref",
        message: `Unknown identifier NotDefined`,
      },
    ]);
  });

  describe("Meta types", () => {
    describe("ModelProperty::type that is an expression", () =>
      itCanReference({
        code: `
          model Person {
            address: {
              @test("target") city: string
            }
          }
        `,
        ref: "Person.address::type.city",
      }));

    describe("ModelProperty::type that is an anonymous model", () =>
      itCanReference({
        code: `
          model A {  a: string }
          model B { b: string }
          model Person {
            @test("target") address: { ...A, ...B };
          }
        `,
        ref: "Person.address::type.a",
        resolveTarget: (prop) => {
          // Abc
          return prop.type.properties.get("a");
        },
      }));

    describe("ModelProperty::type that is an intersection", () =>
      itCanReference({
        code: `
          model A { a: string }
          model B { b: string }
          model Person {
            @test("target") address: A & B;
          }
        `,
        ref: "Person.address::type.a",
        resolveTarget: (prop) => {
          // Abc
          return prop.type.properties.get("a");
        },
      }));
    describe("ModelProperty::type that is a type reference", () =>
      itCanReference({
        code: `
          model Person {
            address: Address
          }
          model Address {
            @test("target") city: string
          }
        `,
        ref: "Person.address::type.city",
      }));
    describe("Operation::returnType", () =>
      itCanReference({
        code: `
          op testOp(): {@test("target")status: 200};
        `,
        ref: "testOp::returnType.status",
      }));

    describe("Operation::parameters", () =>
      itCanReference({
        code: `
          op testOp(@test("target") select: string, other: string): void;
        `,
        ref: "testOp::parameters.select",
      }));

    it("emits a diagnostic when referencing a non-existent meta type property", async () => {
      const diagnostics = await Tester.diagnose(`
        model A {
          name: string;
        }

        model B {
          a: A;
        }

        op testOp(...B::foo): void;
      `);

      expectDiagnostics(diagnostics, [
        {
          code: "invalid-ref",
          message: `Model doesn't have meta property foo`,
        },
      ]);
    });

    it("allows spreading meta type property", async () => {
      const { Spread } = await Tester.compile(t.code`
        model A {
          name: string;
        }

        model B {
          a: A;
        }

        model ${t.model("Spread")} {
          ... B.a::type;
        }
      `);

      strictEqual(Spread.properties.size, 1);
      ok(Spread.properties.get("name"));
    });

    it("the Johan test case", async () => {
      const { azureVersion } = await Tester.compile(t.code`
        model Completion { 
          choices: string[];
        }
        
        op baseVersion("model": string, top_n: int32): Completion;
        
        op ${t.op("azureVersion")}(... baseVersion::parameters, dataSources: string[]): baseVersion::returnType & { rai: string[] };
      `);

      const existingNames = [...azureVersion.parameters.properties.values()].map((v) => v.name);
      strictEqual(existingNames.length, 3);
      ok(existingNames.includes("model"));
      ok(existingNames.includes("top_n"));
      ok(existingNames.includes("dataSources"));
    });
  });
});
