import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import sanitize from 'sanitize-filename';
import { isIP, isIPRange } from 'validator';
import z from 'zod';

export type IsIPRangeOptions = { requireCIDR?: boolean };

function isIPOrRange(value: string, options?: IsIPRangeOptions): boolean {
  const { requireCIDR = true } = options ?? {};
  if (isIPRange(value)) {
    return true;
  }
  if (!requireCIDR && isIP(value)) {
    return true;
  }
  return false;
}

/**
 * Zod schema that validates an array of strings as IP addresses or IP/CIDR ranges.
 * When requireCIDR is true (default), plain IPs are rejected; only CIDR ranges are allowed.
 *
 * @example
 * z.string().optional().transform(...).pipe(IsIPRange())
 * @example
 * z.string().optional().transform(...).pipe(IsIPRange({ requireCIDR: false }))
 */
export function IsIPRange(options?: IsIPRangeOptions) {
  return z
    .array(z.string())
    .refine((arr) => arr.every((item) => isIPOrRange(item, options)), 'Must be an ip address or ip address range');
}

/**
 * Like z.object().partial(), but rejects objects where every field is undefined.
 * Use for update/patch DTOs where at least one field must be provided.
 *
 * @example
 * nonEmptyPartial({ name: z.string(), bio: z.string() }).meta({ id: 'UpdateDto' });
 */
export function nonEmptyPartial<T extends z.ZodRawShape>(shape: T) {
  return z
    .object(shape)
    .partial()
    .refine((data) => Object.values(data as Record<string, unknown>).some((value) => value !== undefined), {
      message: 'At least one field must be provided',
    });
}

/**
 * Zod schema that validates sibling-exclusion for object schemas.
 * Validation passes when the target property is missing, or when none of the sibling properties are present.
 * Use with .pipe() like IsIPRange.
 *
 * @example
 * const Schema = z.object({ a: z.string().optional(), b: z.string().optional() });
 * Schema.pipe(IsNotSiblingOf(Schema, 'a', ['b']));
 */
export function IsNotSiblingOf<
  TSchema extends z.ZodObject<z.ZodRawShape>,
  TKey extends z.infer<ReturnType<TSchema['keyof']>> & keyof z.infer<TSchema>,
>(_schema: TSchema, property: TKey, siblings: TKey[]) {
  type T = z.infer<TSchema>;
  const message = `${String(property)} cannot exist alongside ${siblings.join(' or ')}`;
  return z.custom<T>().refine(
    (data) => {
      if (data[property] === undefined) {
        return true;
      }
      return siblings.every((sibling) => data[sibling] === undefined);
    },
    { message },
  );
}

@Injectable()
export class ParseMeUUIDPipe extends ParseUUIDPipe {
  async transform(value: string, metadata: ArgumentMetadata) {
    if (value == 'me') {
      return value;
    }
    return super.transform(value, metadata);
  }
}

@Injectable()
export class FileNotEmptyValidator extends FileValidator {
  constructor(private requiredFields: string[]) {
    super({});
    this.requiredFields = requiredFields;
  }

  isValid(files?: any): boolean {
    if (!files) {
      return false;
    }

    return this.requiredFields.every((field) => files[field]);
  }

  buildErrorMessage(): string {
    return `Field(s) ${this.requiredFields.join(', ')} should not be empty`;
  }
}

const UUIDParamSchema = z.object({
  id: z.uuidv4(),
});

export class UUIDParamDto extends createZodDto(UUIDParamSchema) {}

const UUIDAssetIDParamSchema = z.object({
  id: z.uuidv4(),
  assetId: z.uuidv4(),
});

export class UUIDAssetIDParamDto extends createZodDto(UUIDAssetIDParamSchema) {}

const FilenameParamSchema = z.object({
  filename: z.string().regex(/^[a-zA-Z0-9_\-.]+$/, {
    error: 'Filename contains invalid characters',
  }),
});

export class FilenameParamDto extends createZodDto(FilenameParamSchema) {}

export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => {
  const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options;
  return Number.isInteger(value) && value >= min && value <= max;
};

/**
 * Unified email validation
 * Converts email strings to lowercase and validates against HTML5 email regex
 * @docs https://zod.dev/api?id=email
 */
export const toEmail = z
  .email({
    pattern: z.regexes.html5Email,
    error: (iss) => `Invalid input: expected email, received ${typeof iss.input}`,
  })
  .transform((val) => val.toLowerCase());

/**
 * Parse ISO 8601 datetime strings to Date objects
 * @docs https://zod.dev/api?id=codec
 */
export const isoDatetimeToDate = z
  .codec(
    z.iso.datetime({
      error: (iss) => `Invalid input: expected ISO 8601 datetime string, received ${typeof iss.input}`,
    }),
    z.date(),
    {
      decode: (isoString) => new Date(isoString),
      encode: (date) => date.toISOString(),
    },
  )
  .meta({ example: '2024-01-01T00:00:00.000Z' });

/**
 * Parse ISO date strings to Date objects
 * @docs https://zod.dev/api?id=codec
 */
export const isoDateToDate = z
  .codec(
    z.iso.date({
      error: (iss) => `Invalid input: expected ISO date string (YYYY-MM-DD), received ${typeof iss.input}`,
    }),
    z.date(),
    {
      decode: (isoString) => new Date(isoString),
      encode: (date) => date.toISOString().slice(0, 10),
    },
  )
  .meta({ example: '2024-01-01' });

export const isValidTime = z
  .string()
  .regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Invalid input: expected string in HH:mm format, received string');

/**
 * Latitude in range [-90, 90]. Reuse for body or query params.
 *
 * @example
 * // Regular (body): optional coordinates
 * latitudeSchema.optional().describe('Latitude coordinate')
 *
 * @example
 * // Pipe (query): coerce string to number then validate range
 * z.coerce.number().pipe(latitudeSchema).describe('Latitude (-90 to 90)')
 */
export const latitudeSchema = z.number().min(-90).max(90);

/**
 * Longitude in range [-180, 180]. Reuse for body or query params.
 *
 * @example
 * // Regular (body): optional coordinates
 * longitudeSchema.optional().describe('Longitude coordinate')
 *
 * @example
 * // Pipe (query): coerce string to number then validate range
 * z.coerce.number().pipe(longitudeSchema).describe('Longitude (-180 to 180)')
 */
export const longitudeSchema = z.number().min(-180).max(180);

/**
 * Parse string to boolean
 * This should be used for boolean query parameters and path parameters, but not for boolean request body parameters, as the first are always string.
 * We don't use z.coerce.boolean() as any truthy value is considered true
 * z.stringbool() is a more robust way to parse strings to booleans as it lets you specify the truthy and falsy values and the case sensitivity.
 * @docs https://zod.dev/api?id=coercion
 * @docs https://zod.dev/api?id=stringbool
 */
export const stringToBool = z
  .stringbool({ truthy: ['true'], falsy: ['false'], case: 'sensitive' })
  .meta({ type: 'boolean' });

/**
 * Parse JSON strings from multipart/form-data
 */
export const JsonParsed = z.transform((val, ctx) => {
  if (typeof val === 'string') {
    try {
      return JSON.parse(val);
    } catch {
      ctx.issues.push({
        code: 'custom',
        message: `Invalid input: expected JSON string, received ${typeof val}`,
        input: val,
      });
      return z.NEVER;
    }
  }
  return val;
});

/**
 * Hex color validation and normalization.
 * Accepts formats: #RGB, #RGBA, #RRGGBB, #RRGGBBAA (with or without # prefix).
 * Normalizes output to always include the # prefix.
 *
 * @example
 * hexColor.optional()
 */
const hexColorRegex = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export const hexColor = z
  .string()
  .regex(hexColorRegex)
  .transform((val) => (val.startsWith('#') ? val : `#${val}`));

/**
 * Transform empty strings to null. Inner schema passed to this function must accept null.
 * @docs https://zod.dev/api?id=preprocess
 * @example emptyStringToNull(z.string().nullable()).optional() // [encouraged] final schema is optional
 * @example emptyStringToNull(z.string().nullable()) // [encouraged] same as the one above, but final schema is not optional
 * @example emptyStringToNull(z.string().nullish()) // [discouraged] same as the one above, might be confusing
 * @example emptyStringToNull(z.string().optional()) // fails: string schema rejects null
 * @example emptyStringToNull(z.string().nullable()).nullish() // [discouraged] passes, null is duplicated. use the first example instead
 */
export const emptyStringToNull = <T extends z.ZodTypeAny>(schema: T) =>
  z.preprocess((val) => (val === '' ? null : val), schema);

export const sanitizeFilename = z.string().transform((val) => sanitize(val.replaceAll('.', '')));
