import { deepStrictEqual, ok, strictEqual } from "assert";
import { describe, expect, it } from "vitest";
import { getDiscriminatedUnion, isSecret } from "../../src/index.js";
import {
  getDoc,
  getEncode,
  getErrorsDoc,
  getFormat,
  getFriendlyName,
  getKeyName,
  getMediaTypeHint,
  getOverloadedOperation,
  getOverloads,
  getPattern,
  getPatternData,
  getReturnsDoc,
  isErrorModel,
  resolveEncodedName,
  setMediaTypeHint,
} from "../../src/lib/decorators.js";
import { expectDiagnosticEmpty, expectDiagnostics, t } from "../../src/testing/index.js";
import { Tester } from "../tester.js";

describe("compiler: built-in decorators", () => {
  describe("dev comment /** */", () => {
    it("applies /** */ on blockless namespace", async () => {
      const { Foo, program } = await Tester.compile(t.code`
        /** doc for namespace Foo */
        namespace TestDoc.${t.namespace("Foo")};

        model A {}
      `);

      strictEqual(getDoc(program, Foo), "doc for namespace Foo");
    });

    it("applies /** */ on enclosed namespace", async () => {
      const { Foo, program } = await Tester.compile(t.code`
        /** doc for namespace Foo */
        namespace TestDoc.${t.namespace("Foo")} {
           model A {}
        }
      `);

      strictEqual(getDoc(program, Foo), "doc for namespace Foo");
    });

    it("applies /** */ on nested enclosed namespace", async () => {
      const { Foo, program } = await Tester.compile(t.code`
        /** doc for namespace Foo */
        namespace TestDoc.${t.namespace("Foo")} {
          /** doc for namespace Bar */       
          namespace Bar {
            model A {};
          }
        }
      `);

      const Bar = Foo.namespaces.get("Bar")!;
      strictEqual(getDoc(program, Foo), "doc for namespace Foo");
      strictEqual(getDoc(program, Bar), "doc for namespace Bar");
    });

    it("applies /** */ on nested blockless + enclosed namespace", async () => {
      const { Foo, program } = await Tester.compile(t.code`
        /** doc for namespace Foo */
        namespace TestDoc.${t.namespace("Foo")};

        /** doc for namespace Bar */       
        namespace Bar {
          model A {}
        }
      `);

      const Bar = Foo.namespaces.get("Bar")!;
      strictEqual(getDoc(program, Foo), "doc for namespace Foo");
      strictEqual(getDoc(program, Bar), "doc for namespace Bar");
    });
  });

  describe("@doc", () => {
    it("applies @doc on model", async () => {
      const { A, program } = await Tester.compile(t.code`
        @doc("My Doc")
        model ${t.model("A")} { }
      `);

      strictEqual(getDoc(program, A), "My Doc");
    });

    it("formats @doc string using source object", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        @doc("Templated {name}", T)
        model Template<T extends {}>  {
        }

        @doc("Model {name}", A)
        model ${t.model("A")} { }

        model ${t.model("B")} is Template<B> {
        }
      `);
      strictEqual(getDoc(program, A), "Model A");
      strictEqual(getDoc(program, B), "Templated B");
    });

    it("applies @doc on namespace", async () => {
      const { TestDoc, program } = await Tester.compile(t.code`
        @doc("doc for namespace")
        namespace Foo.${t.namespace("TestDoc")} {
        }
      `);

      strictEqual(getDoc(program, TestDoc), "doc for namespace");
    });

    it("applies @doc on enum", async () => {
      const { Color, Red, program } = await Tester.compile(t.code`
        @doc("doc for enum")
        enum ${t.enum("Color")} {
          @doc("doc for enum element")
          ${t.enumMember("Red")}: "red",
        }
      `);

      strictEqual(getDoc(program, Color), "doc for enum");
      strictEqual(getDoc(program, Red), "doc for enum element");
    });

    it("applies @doc on union", async () => {
      const { AB, program } = await Tester.compile(t.code`
        model A { }
        model B { }

        @doc("doc for union")
        union ${t.union("AB")} { a: A, b: B }
      `);

      strictEqual(getDoc(program, AB), "doc for union");
    });

    it("applies @doc on interfaces", async () => {
      const { TestDoc, a, program } = await Tester.compile(t.code`
        @doc("doc for interface")
        interface ${t.interface("TestDoc")} {
          @doc("doc for interface operation")
          ${t.op("a")}(): string;
        }
      `);

      strictEqual(getDoc(program, TestDoc), "doc for interface");
      strictEqual(getDoc(program, a), "doc for interface operation");
    });

    it("applies @doc on operations", async () => {
      const { b, program } = await Tester.compile(t.code`
        @doc("doc for an operation")
        op ${t.op("b")}(): string;
      `);

      strictEqual(getDoc(program, b), "doc for an operation");
    });

    it("emit diagnostic if doc is not a string", async () => {
      const diagnostics = await Tester.diagnose(`
        @doc(123)
        model A { }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-argument",
      });
    });
  });

  describe("@pattern", () => {
    it("applies @pattern to scalar", async () => {
      const { A, program } = await Tester.compile(t.code`
        @pattern("^[a-z]+$")
        scalar ${t.scalar("A")} extends string;
      `);

      strictEqual(getPattern(program, A), "^[a-z]+$");
    });

    it("applies @pattern to model property", async () => {
      const { A, program } = await Tester.compile(t.code`
        model ${t.model("A")} {
          @pattern("^[a-z]+$")
          prop: string;
        }
      `);

      const prop = A.properties.get("prop")!;
      strictEqual(prop.kind, "ModelProperty");
      strictEqual(getPattern(program, prop), "^[a-z]+$");
    });

    it("emit diagnostic if pattern is not a string", async () => {
      const diagnostics = await Tester.diagnose(`
        model A {
          @pattern(123)
          prop: string;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-argument",
      });
    });

    it("emit diagnostic if pattern targe is a non-string union", async () => {
      const diagnostics = await Tester.diagnose(`
        model Employee {
          @pattern("^[a-zA-Z0-9-]{3,24}$")
          name: Test;
        }
        union Test { 1, int32 }
      `);

      expectDiagnostics(diagnostics, {
        code: "decorator-wrong-target",
        message:
          'Cannot apply @pattern decorator to a union type that is not string compatible. The union must explicitly include a string type, and all union values should be strings. For example: union Test { string, "A", "B" }',
      });
    });

    it("allows string union on @pattern", async () => {
      const diagnostics = await Tester.diagnose(`
        model Employee {
          @pattern("^[a-zA-Z0-9-]{3,24}$")
          name: Test;
        }
        union Test { "A", "B", string }
      `);

      expectDiagnosticEmpty(diagnostics);
    });

    it("emit diagnostic if pattern is not a valid RegEx", async () => {
      const diagnostics = await Tester.diagnose(`
        model A {
          @pattern("[a-z")
          prop: string;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-pattern-regex",
      });
    });

    it("optionally allows specifying a pattern validation message", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        @pattern("^[a-z]+$", "Must be all lowercase.")
        scalar ${t.scalar("A")} extends string;

        @pattern("^[a-z]+$")
        scalar ${t.scalar("B")} extends string;
      `);

      const pattern = getPattern(program, A);
      strictEqual(pattern, "^[a-z]+$");
      const data = getPatternData(program, A);
      strictEqual(data?.pattern, pattern);
      strictEqual(data?.validationMessage, "Must be all lowercase.");

      const pattern2 = getPattern(program, B);
      strictEqual(pattern2, "^[a-z]+$");
      const data2 = getPatternData(program, B);
      strictEqual(data2?.pattern, pattern2);
      strictEqual(data2?.validationMessage, undefined);
    });
  });

  describe("@format", () => {
    it("applies @pattern to scalar", async () => {
      const { A, program } = await Tester.compile(t.code`
        @format("email")
        scalar ${t.scalar("A")} extends string;
      `);

      strictEqual(getFormat(program, A), "email");
    });

    it("applies @pattern to model property", async () => {
      const { prop, program } = await Tester.compile(t.code`
        model A {
          @format("email")
          ${t.modelProperty("prop")}: string;
        }
      `);
      strictEqual(getFormat(program, prop), "email");
    });

    it("emit diagnostic if targeting bytes", async () => {
      const diagnostics = await Tester.diagnose(`
        model A {
          @format("email")
          prop: bytes;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "decorator-wrong-target",
        message: "Cannot apply @format decorator to type it is not a string",
      });
    });
  });

  describe("@returnsDoc", () => {
    it("applies @returnsDoc on operation", async () => {
      const { test, program } = await Tester.compile(t.code`
        @returnsDoc("A string")
        op ${t.op("test")}(): string;
      `);

      strictEqual(getReturnsDoc(program, test), "A string");
    });

    it("emit diagnostic if doc is not a string", async () => {
      const diagnostics = await Tester.diagnose(`
        @returnsDoc(123)
        op test(): string;
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-argument",
      });
    });
  });

  describe("@errorsDoc", () => {
    it("applies @errorsDoc on operation", async () => {
      const { test, program } = await Tester.compile(t.code`
        @errorsDoc("An error")
        op ${t.op("test")}(): string;
      `);

      strictEqual(getErrorsDoc(program, test), "An error");
    });

    it("emit diagnostic if doc is not a string", async () => {
      const diagnostics = await Tester.diagnose(`
        @errorsDoc(123)
        op test(): string;
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-argument",
      });
    });
  });

  describe("@friendlyName", () => {
    it("applies @friendlyName on model", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        @friendlyName("MyNameIsA")
        model ${t.model("A")} { }

        @friendlyName("{name}Model", B)
        model ${t.model("B")} { }

        @friendlyName("Templated{name}", T)
        model Templated<T> {
          prop: T;
        }
      `);
      strictEqual(getFriendlyName(program, A), "MyNameIsA");
      strictEqual(getFriendlyName(program, B), "BModel");
    });

    it(" @friendlyName doesn't carry over to derived models", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        @friendlyName("MyNameIsA")
        model ${t.model("A")} { t: string; }

        model ${t.model("B")} is A { }
      `);
      strictEqual(getFriendlyName(program, A), "MyNameIsA");
      strictEqual(getFriendlyName(program, B), undefined);
    });
  });

  describe("@error", () => {
    it("applies @error on model", async () => {
      const { A, program } = await Tester.compile(t.code`
        @error
        model ${t.model("A")} { }
      `);
      ok(isErrorModel(program, A), "isError should be true");
    });

    it("applies @error on derived models", async () => {
      const { B, C, program } = await Tester.compile(t.code`
        @error model A { }
        model ${t.model("B")} extends A { }
        model ${t.model("C")} extends B { }
      `);
      ok(isErrorModel(program, B), "isError should be true");
      ok(isErrorModel(program, C), "isError should be true");
    });

    it("emit diagnostic if error is not applied to a model", async () => {
      const diagnostics = await Tester.diagnose(`
        @error
        enum A { B, C }
        `);

      strictEqual(diagnostics.length, 1);
      strictEqual(diagnostics[0].code, "decorator-wrong-target");
      strictEqual(
        diagnostics[0].message,
        `Cannot apply @error decorator to A since it is not assignable to Model`,
      );
    });
  });

  describe("@key", () => {
    it("emits diagnostic when argument is not a string", async () => {
      const diagnostics = await Tester.diagnose(
        `model M {
          @key(4)
          prop: string;
        }`,
      );

      expectDiagnostics(diagnostics, [
        {
          code: "invalid-argument",
        },
      ]);
    });

    it("emits diagnostic when not applied to model property", async () => {
      const diagnostics = await Tester.diagnose(
        `@key
        model M {}`,
      );

      expectDiagnostics(diagnostics, [
        {
          code: "decorator-wrong-target",
          message: "Cannot apply @key decorator to M since it is not assignable to ModelProperty",
        },
      ]);
    });

    it("sets key to property name by default", async () => {
      const { prop, program } = await Tester.compile(t.code`
        model M {
          @key
          ${t.modelProperty("prop")}: string;
        }
      `);

      strictEqual(prop.kind, "ModelProperty" as const);
      strictEqual(getKeyName(program, prop), "prop");
    });

    it("sets key to alternate name when provided", async () => {
      const { prop, program } = await Tester.compile(t.code`
        model M {
          @key("alternateName")
          ${t.modelProperty("prop")}: string;
        }
      `);

      strictEqual(prop.kind, "ModelProperty" as const);
      strictEqual(getKeyName(program, prop), "alternateName");
    });

    it("getKeyName returns undefined if used on property not annotated with @key", async () => {
      const { prop, program } = await Tester.compile(t.code`
        model M {
          ${t.modelProperty("prop")}: string;
        }
      `);

      strictEqual(prop.kind, "ModelProperty" as const);
      strictEqual(getKeyName(program, prop), undefined);
    });

    it("emits diagnostic when key property is marked as optional", async () => {
      const diagnostics = await Tester.diagnose(
        `model M {
          @key
          prop?: string;
        }`,
      );

      expectDiagnostics(diagnostics, [
        {
          code: "no-optional-key",
          message: "Property 'prop' marked as key cannot be optional.",
        },
      ]);
    });
  });

  describe("@encode", () => {
    it(`set encoding on scalar`, async () => {
      const { s, program } = await Tester.compile(t.code`
        @encode("rfc3339")
        scalar ${t.scalar("s")} extends utcDateTime;
      `);

      strictEqual(getEncode(program, s)?.encoding, "rfc3339");
    });

    it(`set encoding on model property`, async () => {
      const { prop, program } = await Tester.compile(t.code`
        model Foo {
          @encode("rfc3339")
          ${t.modelProperty("prop")}: utcDateTime;
        }
      `);

      strictEqual(getEncode(program, prop)?.encoding, "rfc3339");
    });

    it(`set encoding on model property of union type`, async () => {
      const { prop, program } = await Tester.compile(t.code`
        model Foo {
          @encode("rfc3339")
          ${t.modelProperty("prop")}: utcDateTime | null; 
        }
      `);

      strictEqual(getEncode(program, prop)?.encoding, "rfc3339");
    });

    it(`encode type default to string`, async () => {
      const { s, program } = await Tester.compile(t.code`
        @encode("rfc3339")
        scalar ${t.scalar("s")} extends utcDateTime;
      `);

      strictEqual(getEncode(program, s)?.type.name, "string");
    });

    it(`change encode type`, async () => {
      const { s, program } = await Tester.compile(t.code`
        @encode("unixTimestamp", int32)
        scalar ${t.scalar("s")} extends utcDateTime;
      `);

      strictEqual(getEncode(program, s)?.type.name, "int32");
    });

    describe("known encoding validation", () => {
      const validCases = [
        ["utcDateTime", "rfc3339", undefined],
        ["utcDateTime", "rfc7231", undefined],
        ["offsetDateTime", "rfc3339", undefined],
        ["offsetDateTime", "rfc7231", undefined],
        ["utcDateTime", "unixTimestamp", "int32"],
        ["duration", "ISO8601", undefined],
        ["duration", "seconds", "int32"],
        ["duration", "milliseconds", "int32"],
        ["bytes", "base64", undefined],
        ["bytes", "base64url", undefined],
        // Do not block unknown encoding
        ["utcDateTime", "custom-encoding", undefined],
        ["duration", "custom-encoding", "int32"],
      ] as const;
      const invalidCases = [
        [
          "utcDateTime",
          "rfc3339",
          "int32",
          "invalid-encode",
          `Encoding 'rfc3339' on type 's' is expected to be serialized as 'string' but got 'int32'.`,
        ],
        [
          "offsetDateTime",
          "rfc7231",
          "int64",
          "invalid-encode",
          `Encoding 'rfc7231' on type 's' is expected to be serialized as 'string' but got 'int64'.`,
        ],
        [
          "offsetDateTime",
          "unixTimestamp",
          "int32",
          "invalid-encode",
          `Encoding 'unixTimestamp' cannot be used on type 's'. Expected: utcDateTime.`,
        ],
        [
          "utcDateTime",
          "unixTimestamp",
          "string",
          "invalid-encode",
          `Encoding 'unixTimestamp' on type 's' is expected to be serialized as 'integer' but got 'string'. Set '@encode' 2nd parameter to be of type integer. e.g. '@encode("unixTimestamp", int32)'`,
        ],
        [
          "duration",
          "seconds",
          undefined,
          "invalid-encode",
          `Encoding 'seconds' on type 's' is expected to be serialized as 'numeric' but got 'string'. Set '@encode' 2nd parameter to be of type numeric. e.g. '@encode("seconds", int32)'`,
        ],
        [
          "duration",
          "milliseconds",
          undefined,
          "invalid-encode",
          `Encoding 'milliseconds' on type 's' is expected to be serialized as 'numeric' but got 'string'. Set '@encode' 2nd parameter to be of type numeric. e.g. '@encode("milliseconds", int32)'`,
        ],
        [
          "duration",
          "rfc3339",
          undefined,
          "invalid-encode",
          `Encoding 'rfc3339' cannot be used on type 's'. Expected: utcDateTime, offsetDateTime.`,
        ],
        [
          "bytes",
          "rfc3339",
          undefined,
          "invalid-encode",
          `Encoding 'rfc3339' cannot be used on type 's'. Expected: utcDateTime, offsetDateTime.`,
        ],
        [
          "duration",
          "seconds",
          '"int32"',
          // TODO: Arguably this should be improved.
          "invalid-argument",
          `Argument of type '"int32"' is not assignable to parameter of type 'Scalar'`,
        ],
        [
          "duration",
          "milliseconds",
          '"int32"',
          // TODO: Arguably this should be improved.
          "invalid-argument",
          `Argument of type '"int32"' is not assignable to parameter of type 'Scalar'`,
        ],
      ];
      describe("valid", () => {
        validCases.forEach(([target, encoding, encodeAs]) => {
          it(`encoding '${encoding}' on ${target} encoded as ${encodeAs ?? "string"}`, async () => {
            const encodeAsParam = encodeAs ? `, ${encodeAs}` : "";
            const { s, program } = await Tester.compile(t.code`
          @encode("${encoding}"${encodeAsParam})
          scalar ${t.scalar("s")} extends ${target};
        `);

            const encodeData = getEncode(program, s);
            ok(encodeData);
            strictEqual(encodeData.encoding, encoding);
            strictEqual(encodeData.type.name, encodeAs ?? "string");
          });
        });

        it(`@encode(string) on numeric scalar`, async () => {
          const { s, program } = await Tester.compile(t.code`
            @encode(string)
            scalar ${t.scalar("s")} extends int64;
          `);

          const encodeData = getEncode(program, s);
          ok(encodeData);
          strictEqual(encodeData.encoding, undefined);
          strictEqual(encodeData.type.name, "string");
        });
      });
      describe("invalid", () => {
        invalidCases.forEach(([target, encoding, encodeAs, expectedCode, expectedMessage]) => {
          it(`encoding '${encoding}' on ${target}  encoded as ${
            encodeAs ?? "string"
          }`, async () => {
            const encodeAsParam = encodeAs ? `, ${encodeAs}` : "";
            const diagnostics = await Tester.diagnose(`
          @encode("${encoding}"${encodeAsParam})
          scalar s extends ${target};
        `);
            expectDiagnostics(diagnostics, {
              code: expectedCode,
              severity: "error",
              message: expectedMessage,
            });
          });
        });

        it(`@encode(string) on non-numeric scalar`, async () => {
          const diagnostics = await Tester.diagnose(`
            @encode(string)
            scalar s extends utcDateTime;
          `);

          expectDiagnostics(diagnostics, {
            code: "invalid-encode",
            severity: "error",
            message: "Encoding 'string' cannot be used on type 's'. Expected: numeric.",
          });
        });
      });
    });

    describe("ArrayEncoding enum", () => {
      it("can use ArrayEncoding.pipeDelimited", async () => {
        const { prop, program } = await Tester.compile(t.code`
          model Foo {
            @encode(ArrayEncoding.pipeDelimited)
            ${t.modelProperty("prop")}: string[];
          }
        `);

        strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.pipeDelimited");
      });

      it("can use ArrayEncoding.spaceDelimited", async () => {
        const { prop, program } = await Tester.compile(t.code`
          model Foo {
            @encode(ArrayEncoding.spaceDelimited)
            ${t.modelProperty("prop")}: string[];
          }
        `);

        strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.spaceDelimited");
      });

      it("can use ArrayEncoding.commaDelimited", async () => {
        const { prop, program } = await Tester.compile(t.code`
          model Foo {
            @encode(ArrayEncoding.commaDelimited)
            ${t.modelProperty("prop")}: string[];
          }
        `);

        strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.commaDelimited");
      });

      it("can use ArrayEncoding.newlineDelimited", async () => {
        const { prop, program } = await Tester.compile(t.code`
          model Foo {
            @encode(ArrayEncoding.newlineDelimited)
            ${t.modelProperty("prop")}: string[];
          }
        `);

        strictEqual(getEncode(program, prop)?.encoding, "ArrayEncoding.newlineDelimited");
      });
    });
  });

  describe("@withoutOmittedProperties", () => {
    it("removes a model property when given a string literal", async () => {
      const { TestModel } = await Tester.compile(t.code`
        model OriginalModel {
          removeMe: string;
          notMe: string;
        }

        model ${t.model("TestModel")} is OmitProperties<OriginalModel, "removeMe"> {
        }
      `);

      const properties = Array.from(TestModel.properties.keys());
      deepStrictEqual(properties, ["notMe"]);
    });

    it("removes model properties when given a union containing strings", async () => {
      const { TestModel } = await Tester.compile(t.code`
        model OriginalModel {
          removeMe: string;
          removeMeToo: string;
          notMe: string;
        }

        model ${t.model("TestModel")} is OmitProperties<OriginalModel, "removeMe" | "removeMeToo"> {
        }
      `);

      const properties = Array.from(TestModel.properties.keys());
      deepStrictEqual(properties, ["notMe"]);
    });
  });

  describe("@withPickedProperties", () => {
    it("picks a model property when given a string literal", async () => {
      const { TestModel } = await Tester.compile(t.code`
        model OriginalModel {
          pickMe: string;
          notMe: string;
        }

        model ${t.model("TestModel")} is PickProperties<OriginalModel, "pickMe"> {
        }
      `);

      const properties = Array.from(TestModel.properties.keys());
      deepStrictEqual(properties, ["pickMe"]);
    });

    it("picks model properties when given a union containing strings", async () => {
      const { TestModel } = await Tester.compile(t.code`
        model OriginalModel {
          pickMe: string;
          pickMeToo: string;
          notMe: string;
        }

        model ${t.model("TestModel")} is PickProperties<OriginalModel, "pickMe" | "pickMeToo"> {
        }
      `);

      const properties = Array.from(TestModel.properties.keys());
      deepStrictEqual(properties, ["pickMe", "pickMeToo"]);
    });
  });

  describe("@overload", () => {
    it("emits an error when @overload is given something other than an operation", async () => {
      const diagnostics = await Tester.diagnose(`
        @overload("foo")
        op someStringThing(param: string): string;
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-argument",
        message: `Argument of type '"foo"' is not assignable to parameter of type 'Operation'`,
        severity: "error",
      });
    });

    it("emits an error when the overload's parameters are unrelated to the overloaded operation", async () => {
      const diagnostics = await Tester.diagnose(`
        op someThing(param: string | int32): string | int32;

        @overload(someThing)
        op someUnrelatedThing(foo: boolean): string;

        @overload(someThing)
        op anotherUnrelatedThing(param: boolean): string;

        @overload(someThing)
        op thisOneWorks(param: string, foo: int32): string;
      `);

      expectDiagnostics(diagnostics, [
        {
          code: "missing-property",
          message:
            "Property 'param' is missing on type '{ foo: boolean }' but required in '{ param: string | int32 }'",
          severity: "error",
        },
        {
          code: "unassignable",
          message: "Type 'boolean' is not assignable to type 'string | int32'",
          severity: "error",
        },
      ]);
    });

    it("can define operation overloads outside of a namespace or interface", async () => {
      const { someThing, someStringThing, someNumberThing, someUnrelatedThing, program } =
        await Tester.compile(t.code`
        op ${t.op("someThing")}(param: string | int32): string | int32;

        @overload(someThing)
        op ${t.op("someStringThing")}(param: string): string;

        @overload(someThing)
        op ${t.op("someNumberThing")}(param: int32): int32;

        op ${t.op("someUnrelatedThing")}(): void;

      `);

      strictEqual(someThing.kind, "Operation");
      ok(getOverloadedOperation(program, someStringThing));
      ok(getOverloadedOperation(program, someNumberThing));
      ok(!getOverloadedOperation(program, someThing));
      ok(!getOverloadedOperation(program, someUnrelatedThing));
      const overloadedBy = getOverloads(program, someThing)?.map((op) => op.name);
      ok(overloadedBy?.length === 2);
      ok(overloadedBy?.includes("someStringThing"));
      ok(overloadedBy?.includes("someNumberThing"));
      ok(getOverloads(program, someUnrelatedThing) === undefined);
    });

    it("can overload operations defined in a namespace", async () => {
      const { someThing, someStringThing, someNumberThing, program } = await Tester.compile(t.code`
        namespace MyArea {
          op ${t.op("someThing")}(param: string | int32): string | int32;

          @overload(someThing)
          op ${t.op("someStringThing")}(param: string): string;

          @overload(MyArea.someThing)
          op ${t.op("someNumberThing")}(param: int32): int32;
        }

      `);

      strictEqual(someThing.kind, "Operation");
      ok(getOverloadedOperation(program, someStringThing));
      ok(getOverloadedOperation(program, someNumberThing));
      ok(!getOverloadedOperation(program, someThing));
      const overloadedBy = getOverloads(program, someThing)?.map((op) => op.name);
      ok(overloadedBy?.length === 2);
      ok(overloadedBy?.includes("someStringThing"));
      ok(overloadedBy?.includes("someNumberThing"));
    });

    it("can overload operations defined in an interface", async () => {
      const { someThing, someStringThing, someNumberThing, program } = await Tester.compile(t.code`
        interface SomeInterface {
          op ${t.op("someThing")}(param: string | int32): string | int32;

          @overload(SomeInterface.someThing)
          op ${t.op("someStringThing")}(param: string): string;

          @overload(SomeInterface.someThing)
          op ${t.op("someNumberThing")}(param: int32): int32;
        }
      `);

      strictEqual(someThing.kind, "Operation");
      ok(getOverloadedOperation(program, someStringThing));
      ok(getOverloadedOperation(program, someNumberThing));
      ok(!getOverloadedOperation(program, someThing));
      const overloadedBy = getOverloads(program, someThing)?.map((op) => op.name);
      ok(overloadedBy?.length === 2);
      ok(overloadedBy?.includes("someStringThing"));
      ok(overloadedBy?.includes("someNumberThing"));
    });

    describe("overloads must have the same parent as the overload base", () => {
      it("emit diagnostic if outside of interface", async () => {
        const diagnostics = await Tester.diagnose(`
          interface SomeInterface {
            someThing(param: string | int32): string | int32;
          }
  
          @overload(SomeInterface.someThing)
          op someStringThing(param: string): string;
        `);
        expectDiagnostics(diagnostics, {
          code: "overload-same-parent",
          message: "Overload must be in the same interface or namespace.",
        });
      });

      it("emit diagnostic if outside of namespace", async () => {
        const diagnostics = await Tester.diagnose(`
          namespace SomeNamespace {
            op someThing(param: string | int32): string | int32;
          }
  
          @overload(SomeNamespace.someThing)
          op someStringThing(param: string): string;
        `);
        expectDiagnostics(diagnostics, {
          code: "overload-same-parent",
          message: "Overload must be in the same interface or namespace.",
        });
      });

      it("emit diagnostic if different interface", async () => {
        const diagnostics = await Tester.diagnose(`
          interface SomeInterface {
            someThing(param: string | int32): string | int32;
          }
  
          interface OtherInterface {
            @overload(SomeInterface.someThing)
            someStringThing(param: string): string;
          }
        `);
        expectDiagnostics(diagnostics, {
          code: "overload-same-parent",
          message: "Overload must be in the same interface or namespace.",
        });
      });

      it("emit diagnostic if different namespace", async () => {
        const diagnostics = await Tester.diagnose(`
          namespace SomeNamespace {
            op someThing(param: string | int32): string | int32;
          }
  
          namespace OtherNamespace {
            @overload(SomeNamespace.someThing)
            op someStringThing(param: string): string;
          }
        `);
        expectDiagnostics(diagnostics, {
          code: "overload-same-parent",
          message: "Overload must be in the same interface or namespace.",
        });
      });

      it("emit diagnostic if in an interface but base isn't", async () => {
        const diagnostics = await Tester.diagnose(`
          op someThing(param: string | int32): string | int32;
  
          interface OtherInterface {
            @overload(someThing)
            someStringThing(param: string): string;
          }
        `);
        expectDiagnostics(diagnostics, {
          code: "overload-same-parent",
          message: "Overload must be in the same interface or namespace.",
        });
      });
    });
  });

  describe("@secret", () => {
    it("can be applied on a string model", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        scalar ${t.scalar("A")} extends string;
      `);

      ok(isSecret(program, A));
    });

    it("can be applied on a model property with string type", async () => {
      const { A, program } = await Tester.compile(t.code`
        model ${t.model("A")} {
          @secret
          a: string;
        }
      `);

      ok(isSecret(program, A.properties.get("a")!));
    });

    it("can be applied on a model property with stringlike model as type", async () => {
      const { A, program } = await Tester.compile(t.code`
        scalar CustomStr extends string;

        model ${t.model("A")} {
          @secret
          a: CustomStr;
        }
      `);

      ok(isSecret(program, A.properties.get("a")!));
    });

    it("can be applied on a model", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        model ${t.model("A")} {}
      `);

      ok(isSecret(program, A));
    });

    it("can be applied on a non-string scalar", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        scalar ${t.scalar("A")} extends int32;
      `);

      ok(isSecret(program, A));
    });

    it("can be applied on a model property with non-string type", async () => {
      const { A, program } = await Tester.compile(t.code`
        model ${t.model("A")} {
          @secret
          a: int32;
        }
      `);

      ok(isSecret(program, A.properties.get("a")!));
    });

    it("can be applied on a union", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        union ${t.union("A")} {
          x: string,
          y: int32
        }
      `);

      ok(isSecret(program, A));
    });

    it("can be applied on an enum", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        enum ${t.enum("A")} {
          One: "one",
          Two: "two"
        }
      `);

      ok(isSecret(program, A));
    });

    it("can be applied on a model property with model type", async () => {
      const { A, program } = await Tester.compile(t.code`
        @secret
        model SecretModel {
          data: string;
        }

        model ${t.model("A")} {
          @secret
          secret: SecretModel;
        }
      `);

      ok(isSecret(program, A.properties.get("secret")!));
    });

    it("can be applied on a model property with union type", async () => {
      const { A, program } = await Tester.compile(t.code`
        union SecretUnion {
          x: string,
          y: int32
        }

        model ${t.model("A")} {
          @secret
          secret: SecretUnion;
        }
      `);

      ok(isSecret(program, A.properties.get("secret")!));
    });

    it("can be applied on a model property with enum type", async () => {
      const { A, program } = await Tester.compile(t.code`
        enum SecretEnum {
          One: "one",
          Two: "two"
        }

        model ${t.model("A")} {
          @secret
          secret: SecretEnum;
        }
      `);

      ok(isSecret(program, A.properties.get("secret")!));
    });
  });

  describe("@discriminated", () => {
    it("error if more than one unnamed variant", async () => {
      const diagnostics = await Tester.diagnose(`
        @discriminated
        union Foo {
          "A",
          "B"
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-discriminated-union-variant",
        message: `Discriminated union Foo only allow a single default variant(Without a variant name).`,
      });
    });

    it("error if using no envelope and variant name mismatch with property", async () => {
      const diagnostics = await Tester.diagnose(`
        model A {
          kind: "a-kind",
        }
        @discriminated(#{envelope: "none"})
        union Foo {
          a: A;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-discriminated-union-variant",
        message: `Variant "a" explicitly defines the discriminator property "kind" but the value "a-kind" do not match the variant name "a".`,
      });
    });

    async function getTestDiscriminatedUnion(code: string) {
      const { Foo, program } = (await Tester.compile(code)) as any;
      return getDiscriminatedUnion(program, Foo)[0]!;
    }

    it("discriminated by default", async () => {
      const union = await getTestDiscriminatedUnion(`
        @discriminated
        union /*Foo*/Foo {
        }
      `);

      expect(union?.options).toEqual({
        envelope: "object",
        discriminatorPropertyName: "kind",
        envelopePropertyName: "value",
      });
    });

    it("change discriminator", async () => {
      const union = await getTestDiscriminatedUnion(`
        @discriminated(#{discriminatorPropertyName: "dataKind"})
        union /*Foo*/Foo {
        }
      `);

      expect(union?.options).toEqual({
        envelope: "object",
        discriminatorPropertyName: "dataKind",
        envelopePropertyName: "value",
      });
    });

    it("change envelopePropertyName", async () => {
      const union = await getTestDiscriminatedUnion(`
        @discriminated(#{envelopePropertyName: "data"})
        union /*Foo*/Foo {
        }
      `);

      expect(union?.options).toEqual({
        envelope: "object",
        discriminatorPropertyName: "kind",
        envelopePropertyName: "data",
      });
    });

    it("set envelope: none", async () => {
      const union = await getTestDiscriminatedUnion(`
        @discriminated(#{envelope: "none"})
        union /*Foo*/Foo {
        }
      `);

      expect(union?.options).toEqual({
        envelope: "none",
        discriminatorPropertyName: "kind",
        envelopePropertyName: "value",
      });
    });
  });

  describe("@encodedName", () => {
    it("emit error if passing invalid mime type", async () => {
      const diagnostics = await Tester.diagnose(`
        model Cert {
          @encodedName("foo/bar/baz", "exp")
          expireAt: utcDateTime;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "invalid-mime-type",
        message: `Invalid mime type 'foo/bar/baz'`,
      });
    });

    it("emit error if passing mime type with suffix", async () => {
      const diagnostics = await Tester.diagnose(`
        model Cert {
          @encodedName("application/merge-patch+json", "exp")
          expireAt: utcDateTime;
        }
      `);

      expectDiagnostics(diagnostics, {
        code: "no-mime-type-suffix",
        message:
          "Cannot use mime type 'application/merge-patch+json' with suffix 'json'. Use a simple mime `type/subtype` instead.",
      });
    });

    describe("detect conflicts", () => {
      it("emit error if encoded name is same as existing property ", async () => {
        const diagnostics = await Tester.diagnose(`
          model Cert {
            @encodedName("application/json", "exp")
            expireAt: utcDateTime;
            exp: string;
          }
        `);

        expectDiagnostics(diagnostics, {
          code: "encoded-name-conflict",
          message:
            "Encoded name 'exp' conflicts with existing member name for mime type 'application/json'",
        });
      });

      it("emit error if 2 properties use the same encoded name with the same mimeType ", async () => {
        const diagnostics = await Tester.diagnose(`
          model Cert {
            @encodedName("application/json", "exp")
            expireAt: utcDateTime;
            @encodedName("application/json", "exp")
            expireIn: string;
          }
        `);

        expectDiagnostics(diagnostics, [
          {
            code: "encoded-name-conflict",
            message: "Same encoded name 'exp' is used for 2 members 'application/json'",
          },
          {
            code: "encoded-name-conflict",
            message: "Same encoded name 'exp' is used for 2 members 'application/json'",
          },
        ]);
      });

      it("is ok if 2 different mime type have the same encoded name", async () => {
        const diagnostics = await Tester.diagnose(`
          model Cert {
            @encodedName("application/json", "exp")
            expireAt: utcDateTime;
            @encodedName("application/xml", "exp")
            expireIn: string;
          }
        `);

        expectDiagnosticEmpty(diagnostics);
      });
    });

    it("resolve explicit encoded name", async () => {
      const { expireAt, program } = await Tester.compile(t.code`
        model Cert {
          @encodedName("application/json", "exp")
          ${t.modelProperty("expireAt")}: utcDateTime;
        }
      `);
      strictEqual(resolveEncodedName(program, expireAt, "application/json"), "exp");
      strictEqual(resolveEncodedName(program, expireAt, "application/merge-patch+json"), "exp");
    });

    it("resolve default name if no explicit encoded name", async () => {
      const { expireAt, program } = await Tester.compile(t.code`
        model Cert {
          @encodedName("application/json", "exp")
          ${t.modelProperty("expireAt")}: utcDateTime;
        }
      `);
      strictEqual(resolveEncodedName(program, expireAt, "application/xml"), "expireAt");
    });
  });

  describe("@mediaTypeHint", () => {
    it("returns correct media type hint for string", async () => {
      const { A, B, C, program } = await Tester.compile(t.code`
        @mediaTypeHint("application/json")
        scalar ${t.scalar("A")} extends string;

        scalar ${t.scalar("B")} extends A;

        scalar ${t.scalar("C")} extends string;
      `);

      const string = program.checker.getStdType("string");

      strictEqual(getMediaTypeHint(program, A), "application/json");
      strictEqual(getMediaTypeHint(program, string), "text/plain");

      strictEqual(getMediaTypeHint(program, B), "application/json");

      strictEqual(getMediaTypeHint(program, C), "text/plain");
    });

    it("returns correct media type hint for bytes", async () => {
      const { A, B, C, program } = await Tester.compile(t.code`
        @mediaTypeHint("application/json")
        scalar ${t.scalar("A")} extends bytes;

        scalar ${t.scalar("B")} extends A;

        scalar ${t.scalar("C")} extends bytes;
      `);

      strictEqual(getMediaTypeHint(program, A), "application/json");
      strictEqual(getMediaTypeHint(program, A.baseScalar!), "application/octet-stream");

      strictEqual(getMediaTypeHint(program, B), "application/json");

      strictEqual(getMediaTypeHint(program, C), "application/octet-stream");
    });

    it("returns correct media type hint for model", async () => {
      const { A, B, C, D, program } = await Tester.compile(t.code`
        model ${t.model("A")} {}

        @mediaTypeHint("application/xml")
        model ${t.model("B")} extends A {}

        model ${t.model("C")} extends B {}

        @mediaTypeHint("application/json")
        model ${t.model("D")} extends C {}
      `);

      strictEqual(getMediaTypeHint(program, A), undefined);
      strictEqual(getMediaTypeHint(program, B), "application/xml");
      strictEqual(getMediaTypeHint(program, C), "application/xml");
      strictEqual(getMediaTypeHint(program, D), "application/json");
    });

    it("returns correct media type hint for enum", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        enum ${t.enum("A")} { a, b }

        @mediaTypeHint("application/json")
        enum ${t.enum("B")} { a, b }
      `);

      strictEqual(getMediaTypeHint(program, A), undefined);
      strictEqual(getMediaTypeHint(program, B), "application/json");
    });

    it("returns correct media type hint for union", async () => {
      const { A, B, program } = await Tester.compile(t.code`
        union ${t.union("A")} {}

        @mediaTypeHint("text/plain")
        union ${t.union("B")} {}
      `);

      strictEqual(getMediaTypeHint(program, A), undefined);
      strictEqual(getMediaTypeHint(program, B), "text/plain");
    });

    it("can set media type hint programmatically", async () => {
      const { A, program } = await Tester.compile(t.code`
        @test
        model ${t.model("A")} {}
      `);

      strictEqual(getMediaTypeHint(program, A), undefined);
      setMediaTypeHint(program, A, "application/merge-patch+json");
      strictEqual(getMediaTypeHint(program, A), "application/merge-patch+json");
    });

    it("validates media type when set programmatically", async () => {
      const { A, program } = await Tester.compile(t.code`
        @test
        model ${t.model("A")} {}
      `);

      expect(() => setMediaTypeHint(program, A, "not-a-mime-type")).toThrow(
        "Invalid MIME type 'not-a-mime-type' provided to setMediaTypeHint",
      );
    });
  });
});
