import { Effect, Option, Schema, SchemaGetter } from "effect";

import { GoogleDiscoveryParseError } from "./errors";
import {
  GoogleDiscoveryHttpMethod,
  GoogleDiscoveryManifest,
  GoogleDiscoveryManifestMethod,
  GoogleDiscoveryMethodBinding,
  GoogleDiscoveryParameter,
  GoogleDiscoveryParameterLocation,
} from "./types";

type JsonObject = Record<string, unknown>;

const TrimmedString = Schema.Trim;
const Text = Schema.Trim.pipe(Schema.decodeTo(Schema.NonEmptyString));
const LowercaseText = Schema.String.pipe(
  Schema.decode({
    decode: SchemaGetter.transform((value) => value.trim().toLowerCase()),
    encode: SchemaGetter.transform((value) => value.trim().toLowerCase()),
  }),
);
const TextOption = Schema.OptionFromOptional(TrimmedString).pipe(
  Schema.decode({
    decode: SchemaGetter.transform((value) => Option.filter(value, (text) => text.length > 0)),
    encode: SchemaGetter.transform((value) => value),
  }),
  Schema.withDecodingDefaultType(Effect.succeed(Option.none())),
);
const TextArray = Schema.optional(Schema.Array(Text)).pipe(
  Schema.withDecodingDefaultType(Effect.succeed([] as string[])),
);
const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown);
const UnknownRecordWithDefault = Schema.optional(UnknownRecord).pipe(
  Schema.withDecodingDefaultType(Effect.succeed({})),
);

const DiscoveryHttpMethodInput = Schema.optional(
  LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryHttpMethod)),
);

const DiscoveryParameterLocationInput = Schema.optional(
  LowercaseText.pipe(Schema.decodeTo(GoogleDiscoveryParameterLocation)),
);

const DiscoveryDefaultValue = Schema.Union([Schema.String, Schema.Number, Schema.Boolean]);

const DiscoverySchemaModel = Schema.Struct({
  type: Schema.optional(LowercaseText),
  description: TextOption,
  properties: UnknownRecordWithDefault,
  items: Schema.optional(Schema.Unknown),
  additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])),
  enum: TextArray,
  format: Schema.optional(Text),
  readOnly: Schema.optional(Schema.Boolean),
  default: Schema.optional(DiscoveryDefaultValue),
  $ref: Schema.optional(Text),
  required: TextArray,
});
type DiscoverySchema = typeof DiscoverySchemaModel.Type;

const DiscoveryParameterModel = Schema.Struct({
  type: Schema.optional(LowercaseText),
  description: TextOption,
  properties: UnknownRecordWithDefault,
  items: Schema.optional(Schema.Unknown),
  additionalProperties: Schema.optional(Schema.Union([Schema.Boolean, Schema.Unknown])),
  enum: TextArray,
  format: Schema.optional(Text),
  readOnly: Schema.optional(Schema.Boolean),
  default: Schema.optional(DiscoveryDefaultValue),
  $ref: Schema.optional(Text),
  location: DiscoveryParameterLocationInput,
  required: Schema.optional(Schema.Boolean),
  repeated: Schema.optional(Schema.Boolean),
});
type DiscoveryParameter = typeof DiscoveryParameterModel.Type;

const DiscoveryRefModel = Schema.Struct({
  $ref: Schema.optional(Text),
});

const DiscoveryMethodModel = Schema.Struct({
  id: TextOption,
  description: TextOption,
  httpMethod: DiscoveryHttpMethodInput,
  path: TextOption,
  parameters: UnknownRecordWithDefault,
  request: Schema.optional(DiscoveryRefModel),
  response: Schema.optional(DiscoveryRefModel),
  scopes: TextArray,
});
type DiscoveryMethod = typeof DiscoveryMethodModel.Type;

const DiscoveryResourceModel = Schema.Struct({
  methods: UnknownRecordWithDefault,
  resources: UnknownRecordWithDefault,
});

const DiscoveryDocumentModel = Schema.Struct({
  name: TextOption,
  version: TextOption,
  title: TextOption,
  rootUrl: TextOption,
  servicePath: Schema.optional(TrimmedString).pipe(
    Schema.withDecodingDefaultType(Effect.succeed("")),
  ),
  parameters: UnknownRecordWithDefault,
  methods: UnknownRecordWithDefault,
  resources: UnknownRecordWithDefault,
  schemas: UnknownRecordWithDefault,
  auth: Schema.optional(
    Schema.Struct({
      oauth2: Schema.optional(
        Schema.Struct({
          scopes: Schema.optional(
            Schema.Record(
              Schema.String,
              Schema.Struct({
                description: TextOption,
              }),
            ),
          ).pipe(Schema.withDecodingDefaultType(Effect.succeed({}))),
        }),
      ),
    }),
  ),
});
type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;

const decodeUnknownWith =
  <A>(
    message: string,
    decode: (value: unknown) => A,
  ): ((value: unknown) => Effect.Effect<A, GoogleDiscoveryParseError>) =>
  (value) =>
    Effect.try({
      try: () => decode(value),
      // The Schema.TaggedError version of GoogleDiscoveryParseError no
      // longer carries a `cause` field because the client only sees the
      // user-facing message.
      catch: () => new GoogleDiscoveryParseError({ message }),
    });

const decodeDiscoveryDocument = decodeUnknownWith(
  "Failed to decode Google Discovery document",
  Schema.decodeUnknownSync(DiscoveryDocumentModel),
);

const decodeDiscoveryDocumentJson = decodeUnknownWith(
  "Failed to parse Google Discovery document",
  Schema.decodeUnknownSync(Schema.fromJsonString(DiscoveryDocumentModel)),
);

const decodeDiscoverySchema = decodeUnknownWith(
  "Failed to decode Google Discovery schema",
  Schema.decodeUnknownSync(DiscoverySchemaModel),
);

const decodeDiscoveryParameter = decodeUnknownWith(
  "Failed to decode Google Discovery parameter",
  Schema.decodeUnknownSync(DiscoveryParameterModel),
);

const decodeDiscoveryMethod = decodeUnknownWith(
  "Failed to decode Google Discovery method",
  Schema.decodeUnknownSync(DiscoveryMethodModel),
);

const decodeDiscoveryResource = decodeUnknownWith(
  "Failed to decode Google Discovery resource",
  Schema.decodeUnknownSync(DiscoveryResourceModel),
);

const schemaRef = (name: string) => `#/$defs/${name}`;

const toJsonSchemaSeed = (input: {
  type: DiscoverySchema["type"];
  description: DiscoverySchema["description"];
  properties: DiscoverySchema["properties"];
  items: DiscoverySchema["items"];
  additionalProperties: DiscoverySchema["additionalProperties"];
  enum: DiscoverySchema["enum"];
  format: DiscoverySchema["format"];
  readOnly: DiscoverySchema["readOnly"];
  default: DiscoverySchema["default"];
  $ref: DiscoverySchema["$ref"];
  required: DiscoverySchema["required"];
}): DiscoverySchema => ({
  type: input.type,
  description: input.description,
  properties: input.properties ?? {},
  items: input.items,
  additionalProperties: input.additionalProperties,
  enum: input.enum ?? [],
  format: input.format,
  readOnly: input.readOnly,
  default: input.default,
  $ref: input.$ref,
  required: input.required ?? [],
});

const discoverySchemaToJsonSchema = (
  schema: DiscoverySchema,
): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
  Effect.gen(function* () {
    const ref = schema.$ref;
    if (ref) {
      return { $ref: schemaRef(ref) };
    }

    const description = Option.getOrUndefined(schema.description);
    const enumValues = schema.enum ?? [];
    const required = schema.required ?? [];
    const base: Record<string, unknown> = {
      ...(description ? { description } : {}),
      ...(schema.format ? { format: schema.format } : {}),
      ...(enumValues.length > 0 ? { enum: enumValues } : {}),
      ...(schema.readOnly === true ? { readOnly: true } : {}),
      ...(schema.default !== undefined ? { default: schema.default } : {}),
    };

    if (schema.type === "array") {
      return {
        ...base,
        type: "array",
        items: yield* googleSchemaToJsonSchema(schema.items),
      };
    }

    const properties = schema.properties ?? {};
    const additionalProperties = schema.additionalProperties;
    if (
      schema.type === "object" ||
      Object.keys(properties).length > 0 ||
      additionalProperties !== undefined
    ) {
      const convertedProperties = Object.fromEntries(
        yield* Effect.forEach(Object.entries(properties), ([name, property]) =>
          googleSchemaToJsonSchema(property).pipe(
            Effect.map((jsonSchema) => [name, jsonSchema] as const),
          ),
        ),
      );

      const convertedAdditionalProperties =
        additionalProperties === undefined
          ? undefined
          : additionalProperties === true
            ? true
            : yield* googleSchemaToJsonSchema(additionalProperties);

      return {
        ...base,
        type: "object",
        ...(Object.keys(convertedProperties).length > 0 ? { properties: convertedProperties } : {}),
        ...(required.length > 0 ? { required } : {}),
        ...(convertedAdditionalProperties !== undefined
          ? { additionalProperties: convertedAdditionalProperties }
          : {}),
      };
    }

    if (
      schema.type === "boolean" ||
      schema.type === "integer" ||
      schema.type === "number" ||
      schema.type === "string"
    ) {
      return { ...base, type: schema.type };
    }

    if (schema.type === "any") return base;

    return Object.keys(base).length > 0 ? base : {};
  });

const googleSchemaToJsonSchema = (
  rawSchema: unknown,
): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
  rawSchema === undefined
    ? Effect.succeed({})
    : decodeDiscoverySchema(rawSchema).pipe(Effect.flatMap(discoverySchemaToJsonSchema));

const parameterToJsonSchema = (
  parameter: DiscoveryParameter,
): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
  parameter.repeated === true
    ? discoverySchemaToJsonSchema(
        toJsonSchemaSeed({
          type: parameter.type,
          description: parameter.description,
          properties: parameter.properties,
          items: parameter.items,
          additionalProperties: parameter.additionalProperties,
          enum: parameter.enum,
          format: parameter.format,
          readOnly: parameter.readOnly,
          default: parameter.default,
          $ref: parameter.$ref,
          required: [],
        }),
      ).pipe(
        Effect.map((items) => ({
          type: "array",
          items,
        })),
      )
    : discoverySchemaToJsonSchema(
        toJsonSchemaSeed({
          type: parameter.type,
          description: parameter.description,
          properties: parameter.properties,
          items: parameter.items,
          additionalProperties: parameter.additionalProperties,
          enum: parameter.enum,
          format: parameter.format,
          readOnly: parameter.readOnly,
          default: parameter.default,
          $ref: parameter.$ref,
          required: [],
        }),
      );

const toToolPath = (service: string, methodId: string): string => {
  const withoutPrefix = methodId.startsWith(`${service}.`)
    ? methodId.slice(service.length + 1)
    : methodId;
  return withoutPrefix.trim();
};

const toParameter = (
  name: string,
  rawParameter: unknown,
): Effect.Effect<GoogleDiscoveryParameter | null, GoogleDiscoveryParseError> =>
  Effect.gen(function* () {
    const parameter = yield* decodeDiscoveryParameter(rawParameter);
    if (parameter.location === undefined) return null;

    return GoogleDiscoveryParameter.make({
      name,
      location: parameter.location,
      required: parameter.required === true,
      repeated: parameter.repeated === true,
      description: parameter.description,
      schema: Option.some(yield* parameterToJsonSchema(parameter)),
    });
  });

const mergeParameters = (input: {
  globalParameters: Readonly<Record<string, unknown>>;
  method: DiscoveryMethod;
}): Effect.Effect<GoogleDiscoveryParameter[], GoogleDiscoveryParseError> =>
  Effect.gen(function* () {
    const merged = new Map<string, GoogleDiscoveryParameter>();

    for (const [name, parameter] of Object.entries(input.globalParameters)) {
      const converted = yield* toParameter(name, parameter);
      if (converted) merged.set(name, converted);
    }

    for (const [name, parameter] of Object.entries(input.method.parameters ?? {})) {
      const converted = yield* toParameter(name, parameter);
      if (converted) merged.set(name, converted);
    }

    return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
  });

const buildInputSchema = (input: {
  parameters: readonly GoogleDiscoveryParameter[];
  requestRef: string | undefined;
}): unknown | undefined => {
  const properties: Record<string, unknown> = {};
  const required: string[] = [];

  for (const parameter of input.parameters) {
    properties[parameter.name] = Option.getOrElse(parameter.schema, () => ({
      type: "string",
    }));
    if (parameter.required) required.push(parameter.name);
  }

  if (input.requestRef) {
    properties.body = { $ref: schemaRef(input.requestRef) };
  }

  if (Object.keys(properties).length === 0) return undefined;

  return {
    type: "object",
    properties,
    ...(required.length > 0 ? { required } : {}),
    additionalProperties: false,
  };
};

const extractScopes = (document: DiscoveryDocument): Record<string, string> | undefined => {
  const scopes = document.auth?.oauth2?.scopes ?? {};
  const normalized = Object.fromEntries(
    Object.entries(scopes).map(([scope, value]) => [
      scope,
      Option.getOrElse(value.description, () => ""),
    ]),
  );
  return Object.keys(normalized).length > 0 ? normalized : undefined;
};

const manifestMethodFromMethod = (input: {
  service: string;
  rawMethod: unknown;
  globalParameters: Readonly<Record<string, unknown>>;
}): Effect.Effect<GoogleDiscoveryManifestMethod | null, GoogleDiscoveryParseError> =>
  Effect.gen(function* () {
    const method = yield* decodeDiscoveryMethod(input.rawMethod);
    const methodId = Option.getOrUndefined(method.id);
    const path = Option.getOrUndefined(method.path);
    if (!methodId || !path) return null;
    if (!method.httpMethod) {
      return yield* new GoogleDiscoveryParseError({
        message: `Google Discovery method '${methodId}' is missing httpMethod`,
      });
    }

    const requestRef = method.request?.$ref;
    const responseRef = method.response?.$ref;
    const parameters = yield* mergeParameters({
      globalParameters: input.globalParameters,
      method,
    });

    return GoogleDiscoveryManifestMethod.make({
      toolPath: toToolPath(input.service, methodId),
      description: method.description,
      binding: GoogleDiscoveryMethodBinding.make({
        method: method.httpMethod,
        pathTemplate: path,
        parameters,
        hasBody: requestRef !== undefined,
      }),
      inputSchema: Option.fromNullishOr(buildInputSchema({ parameters, requestRef })),
      outputSchema: Option.fromNullishOr(
        responseRef ? { $ref: schemaRef(responseRef) } : undefined,
      ),
      scopes: method.scopes ?? [],
    });
  });

const collectMethods = (input: {
  service: string;
  rawResource: unknown;
  globalParameters: Readonly<Record<string, unknown>>;
}): Effect.Effect<GoogleDiscoveryManifestMethod[], GoogleDiscoveryParseError> =>
  Effect.gen(function* () {
    const resource = yield* decodeDiscoveryResource(input.rawResource);
    const methods = yield* Effect.forEach(Object.values(resource.methods ?? {}), (rawMethod) =>
      manifestMethodFromMethod({
        service: input.service,
        rawMethod,
        globalParameters: input.globalParameters,
      }),
    );
    const nested = yield* Effect.forEach(Object.values(resource.resources ?? {}), (rawResource) =>
      collectMethods({
        ...input,
        rawResource,
      }),
    );

    return [...methods.flatMap((method) => (method ? [method] : [])), ...nested.flat()];
  });

export const extractGoogleDiscoveryManifest = Effect.fn("GoogleDiscovery.extractManifest")(
  function* (discoveryDocument: string | JsonObject) {
    const document =
      typeof discoveryDocument === "string"
        ? yield* decodeDiscoveryDocumentJson(discoveryDocument)
        : yield* decodeDiscoveryDocument(discoveryDocument);

    const service = Option.getOrUndefined(document.name);
    const version = Option.getOrUndefined(document.version);
    const rootUrl = Option.getOrUndefined(document.rootUrl);
    if (!service || !version || !rootUrl) {
      return yield* new GoogleDiscoveryParseError({
        message: "Google Discovery document is missing one of: name, version, rootUrl",
      });
    }

    const schemaDefinitions = Object.fromEntries(
      yield* Effect.forEach(Object.entries(document.schemas ?? {}), ([name, rawSchema]) =>
        googleSchemaToJsonSchema(rawSchema).pipe(Effect.map((schema) => [name, schema] as const)),
      ),
    );

    const topLevelMethods = yield* Effect.forEach(
      Object.values(document.methods ?? {}),
      (rawMethod) =>
        manifestMethodFromMethod({
          service,
          rawMethod,
          globalParameters: document.parameters ?? {},
        }),
    );

    const nestedMethods = yield* Effect.forEach(
      Object.values(document.resources ?? {}),
      (rawResource) =>
        collectMethods({
          service,
          rawResource,
          globalParameters: document.parameters ?? {},
        }),
    );

    return GoogleDiscoveryManifest.make({
      title: document.title,
      service,
      version,
      rootUrl,
      servicePath: document.servicePath ?? "",
      oauthScopes: Option.fromNullishOr(extractScopes(document)),
      schemaDefinitions,
      methods: [
        ...topLevelMethods.flatMap((method) => (method ? [method] : [])),
        ...nestedMethods.flat(),
      ].sort((a, b) => a.toolPath.localeCompare(b.toolPath)),
    });
  },
);
