import { Injectable } from '@nestjs/common';
import InifniBadRequestException from 'src/common/exceptions/infini-badrequest-exception';
import { ERROR_CODES } from 'src/common/constants/error-string-constants';
import { Question, User, FormSection } from 'src/common/entities';
import { RegistrationAnswerDto } from 'src/registration/dto/create-registration.dto';
import { handleKnownErrors } from 'src/common/utils/handle-error.util';
import { getAgeFromDOB, convertFileSizeToBytes, convertBytesToMB } from 'src/common/utils/common.util';
import { LookupDataRepository } from 'src/lookup-data/lookup-data.repository';
import { UserRepository } from 'src/user/user.repository';
import { AppLoggerService } from './logger.service';
import { AwsS3Service } from './awsS3.service';
import { ROLE_KEYS } from '../constants/strings-constants';
import { parsePhoneNumberFromString } from 'libphonenumber-js/max';
import { 
  ANSWER_FIELD_KEYS, 
  CONFIG_FIELD_KEYS, 
  DEPENDENCY_VALUE_LABELS, 
  ENTITY_STATUS, 
  QUESTION_TYPES, 
  VALIDATION_SPECIAL_VALUES,
  TIME_UNITS,
  COMPARISON_OPERATORS,
  DATE_VALIDATION_TYPES,
  URL_PROTOCOLS,
  FILE_SIZE_UNITS,
  SORT_SPECIAL_VALUES,
  EMPTY_VALUES,
  VALIDATION_PATTERNS,
  VALIDATION_LOG_MESSAGES
} from '../constants/string-constants';
import { REGISTRATION_VALIDATION_MESSAGES } from '../constants/validation-string-constants';
import { ERROR_MESSAGES } from '../i18n/error-messages';
import { getValidationDependsOn } from 'src/common/utils/question-config.util';
import { REGISTRATION_BINDING_KEYS } from '../constants/constants';

type QuestionMapping = {
  question: Question;
  formSectionId: number | null;
  programQuestionFormSection: FormSection | null;
};

type LookupContext = Map<string, Array<{ id: number; key: string; value: string }>>;

/**
 * Service for validating registration answers
 * Validates answers against question configurations including dependencies
 * Uses centralized validation constants for error messages
 * Pre-fetches lookup data for apicall questions to optimize validation
 */
@Injectable()
export class RegistrationAnswerValidationService {
  constructor(
    private readonly lookupDataRepository: LookupDataRepository,
    private readonly userRepository: UserRepository,
    private readonly logger: AppLoggerService,
    private readonly awsS3Service: AwsS3Service,
  ) {}
  
  /**
   * Check if answer value is empty
   */
  private isAnswerEmpty(value: unknown): boolean {
    // Null or undefined
    if (value === null || value === undefined) {
      return true;
    }
    
    // Empty string
    if (typeof value === 'string') {
      const trimmed = value.trim();
      // Check for empty string or stringified empty array/object
      if (trimmed === EMPTY_VALUES.EMPTY_STRING || trimmed === EMPTY_VALUES.EMPTY_ARRAY_JSON || trimmed === EMPTY_VALUES.EMPTY_OBJECT_JSON) {
        return true;
      }
      return false;
    }
    
    // Empty array
    if (Array.isArray(value)) {
      return value.length === 0;
    }
    
    // Empty object
    if (typeof value === 'object') {
      return Object.keys(value).length === 0;
    }
    
    return false;
  }
  
  /**
   * Validate section completeness - ensures all required questions in a section are answered
   * Supports cross-section/cross-stage dependencies by requiring ALL answers in 'answers' parameter
   * Also validates all subsections (child sections) recursively based on parentSectionId
   * @param answers - Array of ALL registration answers (from all sections/stages)
   * @param questionMappings - Array of ALL question mappings for the program
   * @param formSectionId - Specific section ID to validate
   * @param context - Program/session/registration context for age-based dependencies
   * @throws InifniBadRequestException if required questions are missing or dependencies not satisfied
   */
  validateSectionCompleteness(answers: RegistrationAnswerDto[], questionMappings: QuestionMapping[], formSectionId: number, context?: { program?: any; session?: any; registration?: any }): void {
    try {
      // Build questionId to bindingKey map for dependency resolution
      const questionIdToBindingKeyMap = this.buildQuestionIdToBindingKeyMap(questionMappings);
      
      // Get all subsections for this section (recursive)
      const subsectionIds = this.getSubsections(formSectionId, questionMappings);
      
      // Sections to validate: current section + all subsections
      const sectionsToValidate = [formSectionId, ...subsectionIds];
      
      this.logger.debug('[VALIDATION] Validating section and subsections', {
        formSectionId,
        subsectionIds,
        totalSections: sectionsToValidate.length
      });
      
      // Validate each section (including subsections)
      for (const sectionId of sectionsToValidate) {
        this.validateSingleSection(answers, questionMappings, sectionId, questionIdToBindingKeyMap, context);
      }
    } catch (error) {
      handleKnownErrors(ERROR_CODES.REGISTRATION_INVALID_VALIDATION, error);
    }
  }

  /**
   * Validate a single section's completeness
   * Internal method called by validateSectionCompleteness for each section/subsection
   */
  private validateSingleSection(
    answers: RegistrationAnswerDto[],
    questionMappings: QuestionMapping[],
    formSectionId: number,
    questionIdToBindingKeyMap: Map<number, string>,
    context?: { program?: any; session?: any; registration?: any }
  ): void {
    // Filter questions for this section
    const sectionQuestions = questionMappings.filter(
      (mapping) => mapping.formSectionId === formSectionId
    );

      if (sectionQuestions.length === 0) {
        return; // No questions in this section
      }

      // Build answer map with ALL answers for cross-section dependency checking
      // ALWAYS use bindingKey as the primary lookup key
      const answerMap = new Map<string, unknown>();
      answers.forEach((answer) => {
        // Get bindingKey from answer or resolve from questionId
        const bindingKey = answer.bindingKey || questionIdToBindingKeyMap.get(answer.questionId);
        
        if (bindingKey) {
          answerMap.set(bindingKey, answer.answer);
        } else {
          // Fallback to questionId string (should be avoided)
          answerMap.set(String(answer.questionId), answer.answer);
          this.logger.warn('[VALIDATION] Answer missing bindingKey, using questionId as fallback', {
            questionId: answer.questionId
          });
        }
      });

      // Get submitted question IDs
      const submittedQuestionIds = new Set(answers.map(a => a.questionId));

      // Check each question in the section
      const missingQuestions: string[] = [];
      const invalidQuestions: string[] = [];
      
      for (const mapping of sectionQuestions) {
        const question = mapping.question;
        const config = (question.config || {}) as Record<string, any>;
        
        // Skip if field is disabled via config.isDisable (e.g., Amount field id:29)
        if (config.isDisable === true) {
          continue;
        }
        
        // Check if dependency has disable type
        const dependsOn = getValidationDependsOn(config);
        const hasDisableType = Array.isArray(dependsOn) && dependsOn.some((dep: any) => dep?.[CONFIG_FIELD_KEYS.TYPE] === DEPENDENCY_VALUE_LABELS.DISABLE);
        
        // Check if dependency is satisfied (always evaluate, even for disable types)
        const isDependencySatisfied = this.isDependencySatisfied(dependsOn, answerMap, context);
        
        // If has disable type AND dependency is satisfied, field is disabled - skip it
        if (hasDisableType && isDependencySatisfied) {
          continue;
        }
        
        // If dependency is not satisfied
        if (!isDependencySatisfied) {
          // For DISABLE-type dependencies: field is ENABLED when dependency not satisfied
          // Continue with required validation below
          if (hasDisableType) {
            // Field is enabled, check if required below
          } else {
            // For NORMAL dependencies: field should not have any value when dependency not satisfied
            // Check if user submitted an answer for this question (they shouldn't have)
            const submittedAnswer = answers.find(a => a.questionId === question.id);
            if (submittedAnswer && !this.isAnswerEmpty(submittedAnswer.answer)) {
              // Find the dependent question details for better error message
              let dependencyInfo = '';
              if (Array.isArray(dependsOn) && dependsOn.length > 0) {
                const depBindingKeys = dependsOn.map(dep => dep.questionBindingKey || 'MISSING_BINDING_KEY').join(', ');
                dependencyInfo = ` (depends on: ${depBindingKeys})`;
              }
              
              this.logger.warn(VALIDATION_LOG_MESSAGES.WARN_FIELD_HAS_VALUE_DEPENDENCY_NOT_SATISFIED, {
                questionId: question.id,
                questionBindingKey: question.bindingKey,
                label: question.label,
                dependencyInfo,
                currentSectionId: formSectionId,
              });
              
              invalidQuestions.push(question.bindingKey || question.label || `Question ID ${question.id}`);
            }
            continue;
          }
        }

        // At this point, field should be visible and validated:
        // - Normal dependency satisfied, OR
        // - Disable-type dependency not satisfied (field enabled)
        // If dependency is satisfied and question is required, it must be present and not empty
        const isRequired = config[CONFIG_FIELD_KEYS.IS_REQUIRED] === true;
        if (isRequired) {
          const submittedAnswer = answers.find(a => a.questionId === question.id);
          const answerValue = submittedAnswer?.answer;
          
          if (!submittedQuestionIds.has(question.id) || this.isAnswerEmpty(answerValue)) {
            missingQuestions.push(question.bindingKey || question.label || `Question ID ${question.id}`);
          }
        }
      }

      const errors: string[] = [];
      if (missingQuestions.length > 0) {
        errors.push(ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MISSING_REQUIRED_QUESTIONS].replace('{0}', missingQuestions.join(', ')));
      }
      if (invalidQuestions.length > 0) {
        errors.push(ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.INVALID_DEPENDENT_ANSWER].replace('{0}', invalidQuestions.join(', ')));
      }

      if (errors.length > 0) {
        throw new InifniBadRequestException(
          ERROR_CODES.REGISTRATION_INVALID_VALIDATION,
          null,
          null,
          errors.join('; '),
        );
      }
  }

  /**
   * Validate completeness for all sections being submitted
   */
  validateAllSectionsCompleteness(answers: RegistrationAnswerDto[], questionMappings: QuestionMapping[]): void {
    try {
      // Get all unique section IDs from the questions being answered
      const answeredQuestionIds = new Set(answers.map(a => a.questionId));
      const sectionsBeingSubmitted = new Set<number>();
      
      questionMappings.forEach((mapping) => {
        if (answeredQuestionIds.has(mapping.question.id) && mapping.formSectionId) {
          sectionsBeingSubmitted.add(mapping.formSectionId);
        }
      });

      // Validate each section
      for (const sectionId of sectionsBeingSubmitted) {
        this.validateSectionCompleteness(answers, questionMappings, sectionId);
      }
    } catch (error) {
      handleKnownErrors(ERROR_CODES.REGISTRATION_INVALID_QUESTION, error);
    }
  }

  /**
   * Validate multiple registration answers
   * @param answers - Registration answers
   * @param questionMappings - Question mappings
   * @param context - Context for validation
   */
  async validateAnswers(
    answers: RegistrationAnswerDto[],
    questionMappings: QuestionMapping[],
    context?: { program?: any; session?: any; registration?: any },
  ): Promise<void> {
    try {
      // Build questionId to bindingKey map
      const questionIdToBindingKeyMap = this.buildQuestionIdToBindingKeyMap(questionMappings);
      
      const questionMap = new Map<number, Question>();
      questionMappings.forEach((mapping) => {
        questionMap.set(mapping.question.id, mapping.question);
      });

      // Build answer map using bindingKey as primary lookup key
      const answerMap = new Map<string, unknown>();
      answers.forEach((answer) => {
        // Get bindingKey from answer or resolve from questionId
        const bindingKey = answer.bindingKey || questionIdToBindingKeyMap.get(answer.questionId);
        
        if (bindingKey) {
          answerMap.set(bindingKey, answer.answer);
        } else {
          // Fallback to questionId string (should be avoided)
          answerMap.set(String(answer.questionId), answer.answer);
          this.logger.warn('[VALIDATION] Answer missing bindingKey in validateAnswers, using questionId as fallback', {
            questionId: answer.questionId
          });
        }
      });

      // Pre-fetch lookup context for all apicall questions
      const lookupContext = await this.fetchLookupContext(questionMappings);

      const validationErrors: string[] = [];
      for (const answer of answers) {
        const question = questionMap.get(answer.questionId);
        if (!question) {
          validationErrors.push(`Invalid question id: ${answer.questionId}`);
          continue;
        }

        const error = await this.validateAnswerByQuestion(question, answer.answer, answerMap, lookupContext, context);
        if (error) {
          validationErrors.push(`${question.bindingKey || question.label}: ${error}`);
        }
      }

      if (validationErrors.length > 0) {
        throw new InifniBadRequestException(
          ERROR_CODES.REGISTRATION_INVALID_VALIDATION,
          null,
          null,
          validationErrors.join('; '),
        );
      }
    } catch (error) {
      handleKnownErrors(ERROR_CODES.REGISTRATION_INVALID_QUESTION, error);
    }
  }

  /**
   * Fetch lookup context for apicall questions
   */
  private async fetchLookupContext(questionMappings: QuestionMapping[]): Promise<LookupContext> {
    try {
      const categories = new Set<string>();
      let needsRMUsers = false;

      // Extract all categories from apicall questions
      for (const mapping of questionMappings) {
        const question = mapping.question;
        if (question.type?.toLowerCase() === QUESTION_TYPES.APICALL) {
          const config = question.config || {};
          const category = config[CONFIG_FIELD_KEYS.CATEGORY];
          if (category === VALIDATION_SPECIAL_VALUES.RM_CATEGORY) {
            needsRMUsers = true;
          } else if (category) {
            categories.add(category);
          }
        }
      }

      // Batch fetch all lookup categories at once
      const lookupContext: LookupContext = await this.lookupDataRepository.findActiveByCategoriesBatch(
        Array.from(categories),
      );

      // Fetch RM users separately if needed
      if (needsRMUsers) {
        const rmUsers = await this.userRepository.getUsersByRoleKey(ROLE_KEYS.RELATIONAL_MANAGER);

        const rmOptions = rmUsers
          .map((u: User) => ({
            id: u.id,
            key: String(u.id),
            value: u.legalFullName || `${u.firstName} ${u.lastName}`.trim(),
          }))
          // Move 'Other' option to end if exists
          .sort((a, b) => {
            if (a.value.toLowerCase() === SORT_SPECIAL_VALUES.OTHER) return 1;
            if (b.value.toLowerCase() === SORT_SPECIAL_VALUES.OTHER) return -1;
            return 0;
          });

        lookupContext.set('RM', rmOptions);
      }

      this.logger.debug(VALIDATION_LOG_MESSAGES.DEBUG_FETCHED_LOOKUP_CONTEXT, {
        categories: Array.from(categories),
        needsRMUsers,
        totalCategories: lookupContext.size,
      });

      return lookupContext;
    } catch (error) {
      this.logger.error(VALIDATION_LOG_MESSAGES.ERROR_FETCHING_LOOKUP_CONTEXT, error.stack);
      // Return empty map on error - validation will check for empty strings
      return new Map();
    }
  }
  /**
   * Validate a single answer against its question configuration
   * @param question - Question configuration
   * @param rawAnswer - Raw answer value
   * @param answerMap - Map of all answers
   * @param lookupContext - Lookup context for apicall validation
   * @param context - Context for validation
   */
  private async validateAnswerByQuestion(
    question: Question,
    rawAnswer: unknown,
    answerMap: Map<string, unknown>,
    lookupContext?: LookupContext,
    context?: { program?: any; session?: any; registration?: any },
  ): Promise<string | null> {
    const config = (question.config || {}) as Record<string, any>;
    const isEmpty = this.isAnswerEmpty(rawAnswer);
    
    this.logger.debug('[VALIDATION] validateAnswerByQuestion START', {
      questionId: question.id,
      bindingKey: question.bindingKey,
      label: question.label,
      hasValue: !isEmpty,
      answerLength: typeof rawAnswer === 'string' ? rawAnswer.length : 'N/A'
    });
    
    // Check dependency satisfaction and type
    const dependsOn = getValidationDependsOn(config);
    const { satisfied: isDependencySatisfied, hasDisableType } = this.checkDependencyWithType(dependsOn, answerMap, context);

    this.logger.debug('[VALIDATION] Dependency check result', {
      questionId: question.id,
      bindingKey: question.bindingKey,
      isDependencySatisfied,
      hasDisableType,
      dependsOn,
    });

    // If dependency not satisfied
    if (!isDependencySatisfied) {
      // For DISABLE-type dependencies: field is ENABLED when dependency not satisfied
      // Example: Mobile number with disable when phoneNumberType="Family member's number"
      // When phoneNumberType != "Family member's number", field is enabled and should be validated
      if (hasDisableType) {
        this.logger.debug('[VALIDATION] Disable-type dependency NOT satisfied - field ENABLED, continuing validation', {
          questionId: question.id,
          bindingKey: question.bindingKey
        });
        // Continue with normal validation below (field is enabled)
      } else {
        // For NORMAL dependencies: field should not have any value when dependency not satisfied
        // Allow empty values (field is hidden, user didn't fill it)
        if (isEmpty) {
          this.logger.debug('[VALIDATION] Normal dependency NOT satisfied, empty value - OK', {
            questionId: question.id,
            bindingKey: question.bindingKey
          });
          return null;
        }
        // Reject non-empty values (field shouldn't be visible/filled)
        this.logger.warn('[VALIDATION] Normal dependency NOT satisfied but has value - REJECT', {
          questionId: question.id,
          bindingKey: question.bindingKey
        });
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DEPENDENCY_VALUE_REJECT];
      }
    } else {
      // Dependency IS satisfied
      // For DISABLE-type dependencies: field is DISABLED when dependency is satisfied
      if (hasDisableType) {
        // Field is disabled, but if user submitted a value, still validate basic format/pattern
        // This ensures data integrity even for disabled fields
        if (isEmpty) {
          this.logger.debug('[VALIDATION] Disable-type dependency satisfied - field DISABLED, empty value - OK', {
            questionId: question.id,
            bindingKey: question.bindingKey
          });
          return null; // Empty is fine for disabled field
        }
        // Non-empty value in disabled field - validate format/pattern but skip required check
        // Continue to format validation below, but skip required validation
        this.logger.debug('[VALIDATION] Disable-type dependency satisfied - field DISABLED but has value, validating format', {
          questionId: question.id,
          bindingKey: question.bindingKey,
        });
      }
    }

    const isRequired = config[CONFIG_FIELD_KEYS.IS_REQUIRED] === true;

    // Required field validation
    // Skip required check if field has disable-type dependency that is satisfied (field is disabled)
    const skipRequiredCheck = hasDisableType && isDependencySatisfied;
    if (isRequired && !skipRequiredCheck && isEmpty) {
      this.logger.debug('[VALIDATION] Required field is empty - FAIL', {
        questionId: question.id,
        bindingKey: question.bindingKey
      });
      return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FIELD_IS_REQUIRED];
    }

    // Skip further validation if not required and empty
    if (!isRequired && isEmpty) {
      this.logger.debug('[VALIDATION] Optional field is empty - OK', {
        questionId: question.id,
        bindingKey: question.bindingKey
      });
      return null;
    }

    this.logger.debug('[VALIDATION] Proceeding with format/pattern validation', {
      questionId: question.id,
      bindingKey: question.bindingKey,
      hasValidationPattern: !!config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN]
    });

    const answerAsString = typeof rawAnswer === 'string' ? rawAnswer : String(rawAnswer);

    // Character length validation
    if (typeof config[CONFIG_FIELD_KEYS.MIN_CHARACTER] === 'number' && answerAsString.length < config[CONFIG_FIELD_KEYS.MIN_CHARACTER]) {
      return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MIN_CHARACTERS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MIN_CHARACTER]));
    }

    if (typeof config[CONFIG_FIELD_KEYS.MAX_CHARACTERS] === 'number' && answerAsString.length > config[CONFIG_FIELD_KEYS.MAX_CHARACTERS]) {
      return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MAX_CHARACTERS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MAX_CHARACTERS]));
    }

    // ========================================
    // PRIMARY VALIDATION: Pattern from Database Config
    // ========================================
    // Guard against empty validation patterns (e.g., ID Front field id:41 has validationPattern: "")
    if (config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN] && config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN].trim() !== '') {
      const regex = new RegExp(config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN]);
      if (!regex.test(answerAsString)) {
        return config[CONFIG_FIELD_KEYS.PATTERN_ERROR_MSG] || ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.INVALID_FORMAT];
      }
    }
    
    // CONDITIONAL VALIDATION: validationConfig for dependent patterns (e.g., ID Number field id:44)
    // Allows different regex patterns based on another field's value (Aadhar vs PAN)
    if (config.validationConfig?.validationPattern && Array.isArray(config.validationConfig.validationPattern)) {
      const dependentBindingKey = config.validationConfig.dependentBindingKey;
      if (dependentBindingKey) {
        const dependentValue = answerMap.get(dependentBindingKey);
        const matchingPattern = config.validationConfig.validationPattern.find((patternConfig: any) => 
          patternConfig.validateif && String(dependentValue) === String(patternConfig.validateif)
        );
        
        if (matchingPattern?.pattern) {
          const conditionalRegex = new RegExp(matchingPattern.pattern);
          if (!conditionalRegex.test(answerAsString)) {
            return `Invalid ${dependentValue} format`;
          }
          // Also validate length if specified
          if (matchingPattern.length && answerAsString.length !== matchingPattern.length) {
            return `${dependentValue} must be exactly ${matchingPattern.length} characters`;
          }
        }
      }
    }
    // GENERIC OPTION VALIDATION - applies to any question type with options
    // If question has options defined, validate answer against those options
    const hasOptions = question.optionConfig && Array.isArray(question.optionConfig) && question.optionConfig.length > 0;
    const questionType = (question.type || '').toLowerCase();

    if (hasOptions && question.optionConfig) {
      // For single-value questions with options (select, radio, checkbox, text with options, etc.)
      const answerValue = answerAsString.trim();
      const validOptions = question.optionConfig;

      if (validOptions.length > 0) {
        const isValid = validOptions.some(
          (option) =>
            option.name === answerValue ||
            option.value === answerValue
        );
        if (!isValid) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.INVALID_OPTION_SELECTION];
        }
      }
    }

    // Number validation
    if (questionType === QUESTION_TYPES.NUMBER) {
      const numericValue = Number(rawAnswer);
      if (Number.isNaN(numericValue)) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.NUMBER_MUST_BE_VALID];
      }
      // Decimal answers (e.g. TDS amount) are validated against their rounded whole number
      // (4.4 -> 4, 4.6 -> 5) rather than the raw decimal.
      const roundedValue = Math.round(numericValue);
      if (typeof config[CONFIG_FIELD_KEYS.MIN_VALUE] === 'number' && roundedValue < config[CONFIG_FIELD_KEYS.MIN_VALUE]) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.NUMBER_MIN_VALUE].replace('{0}', String(config[CONFIG_FIELD_KEYS.MIN_VALUE]));
      }
      if (typeof config[CONFIG_FIELD_KEYS.MAX_VALUE] === 'number' && roundedValue > config[CONFIG_FIELD_KEYS.MAX_VALUE]) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.NUMBER_MAX_VALUE].replace('{0}', String(config[CONFIG_FIELD_KEYS.MAX_VALUE]));
      }
      if (typeof config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS] === 'number' && String(roundedValue).length !== config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS]) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.NUMBER_EXACT_DIGITS].replace('{0}', String(config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS]));
      }
    }

    // ========================================
    // TYPE-SPECIFIC BUSINESS LOGIC VALIDATION
    // ========================================
    // Note: Format validation for email/tel should be defined in config.validationPattern (checked above)
    // Below are additional business rules specific to the question type
    
    // Tel: Phone number validation
    if (questionType === QUESTION_TYPES.TEL) {
      this.logger.debug(VALIDATION_LOG_MESSAGES.DEBUG_VALIDATING_PHONE, { questionId: question.id, answer: answerAsString });
      // Validate digit count if configured
      if (typeof config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS] === 'number') {
        const digitsOnly = answerAsString.replace(VALIDATION_PATTERNS.DIGITS_ONLY, '');
        if (digitsOnly.length !== config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS]) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.PHONE_EXACT_DIGITS].replace('{0}', String(config[CONFIG_FIELD_KEYS.ALLOWED_DIGITS]));
        }
      }

      // Validate international format numbers (starting with +)
      if (answerAsString.startsWith('+')) {
        this.logger.debug(VALIDATION_LOG_MESSAGES.DEBUG_VALIDATING_INTL_PHONE, { questionId: question.id, answer: answerAsString });
        try {
          const phoneNumber = parsePhoneNumberFromString(answerAsString);
          if (!phoneNumber || !phoneNumber.isValid()) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.PHONE_INVALID_FORMAT];
          }
          
          this.logger.debug(VALIDATION_LOG_MESSAGES.DEBUG_PHONE_VALID, { questionId: question.id, answer: answerAsString, country: phoneNumber.country });
        } catch (error) {
          this.logger.warn(`Phone number validation error: ${error.message}`, {
            phoneNumber: answerAsString,
          });
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.PHONE_INVALID_FORMAT];
        }
      }
    }

    // File validation: value is expected to be a URL string or JSON object after upload
    if (questionType === QUESTION_TYPES.FILE) {
      // Check if answer is a JSON object with file metadata or a plain URL
      let fileUrl = answerAsString;
      let fileMetadata: any = null;
      
      try {
        const parsed = JSON.parse(answerAsString);
        if (typeof parsed === 'object' && parsed !== null) {
          fileMetadata = parsed;
          fileUrl = parsed[ANSWER_FIELD_KEYS.URL] || parsed[ANSWER_FIELD_KEYS.FILE_URL] || parsed[ANSWER_FIELD_KEYS.PATH] || answerAsString;
        }
      } catch {
        // Not JSON, treat as plain URL
      }

      if (!fileUrl.startsWith(URL_PROTOCOLS.HTTP) && !fileUrl.startsWith(URL_PROTOCOLS.HTTPS) && !fileUrl.startsWith(URL_PROTOCOLS.DATA)) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_INVALID_URL];
      }

      // Validate S3 file existence for HTTP/HTTPS URLs
      if (fileUrl.startsWith(URL_PROTOCOLS.HTTP) || fileUrl.startsWith(URL_PROTOCOLS.HTTPS)) {
        try {
          const fileExists = await this.awsS3Service.checkFileExists(fileUrl);
          if (!fileExists) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_NOT_EXISTS_IN_STORAGE];
          }
        } catch (error) {
          this.logger.error(VALIDATION_LOG_MESSAGES.ERROR_CHECKING_FILE_URL_S3, error?.stack, { fileUrl, questionId: question.id });
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_VERIFICATION_FAILED];
        }
      }
    

      // Validate file config if provided (filetype, size, allowed formats)
      if (config[CONFIG_FIELD_KEYS.FILE_CONFIG]) {
        const fileConfig = config[CONFIG_FIELD_KEYS.FILE_CONFIG];
        
        // Validate file type/format - use 'accepts' key (actual data structure) not 'allowedFormats'
        const acceptsKey = fileConfig.accepts || fileConfig[CONFIG_FIELD_KEYS.ALLOWED_FORMATS];
        if (acceptsKey && Array.isArray(acceptsKey)) {
          if (fileMetadata && fileMetadata[ANSWER_FIELD_KEYS.TYPE]) {
            const fileType = fileMetadata[ANSWER_FIELD_KEYS.TYPE].toLowerCase();
            const isValidFormat = acceptsKey.some(
              (format: string) => fileType.includes(format.toLowerCase().replace('.', ''))
            );
            if (!isValidFormat) {
              return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_INVALID_FORMAT].replace('{0}', acceptsKey.join(', '));
            }
          }
        }

        // Validate file size - support both maxSize (legacy) and sizeLimit.maxValue (video uploads id:18)
        let maxSizeBytes: number | null = null;
        
        if (fileConfig.sizeLimit?.maxValue) {
          // Video file size limit: sizeLimit.maxValue in MB with sizeLimit.unit
          const unit = fileConfig.sizeLimit.unit?.toLowerCase() || FILE_SIZE_UNITS.MB_LOWER;
          maxSizeBytes = convertFileSizeToBytes(fileConfig.sizeLimit.maxValue, unit);
        } else if (fileConfig[CONFIG_FIELD_KEYS.MAX_SIZE]) {
          // Legacy format: maxSize as "50MB" string or number
          maxSizeBytes = convertFileSizeToBytes(fileConfig[CONFIG_FIELD_KEYS.MAX_SIZE]);
        }
        
        if (maxSizeBytes && fileMetadata && fileMetadata[ANSWER_FIELD_KEYS.SIZE]) {
          if (fileMetadata[ANSWER_FIELD_KEYS.SIZE] > maxSizeBytes) {
            const maxSizeMB = convertBytesToMB(maxSizeBytes);
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_SIZE_EXCEEDS].replace('{0}', maxSizeMB);
          }
        }
      }

      // Validate filetype config (legacy support)
      if (config[CONFIG_FIELD_KEYS.FILE_TYPE] && Array.isArray(config[CONFIG_FIELD_KEYS.FILE_TYPE])) {
        if (fileMetadata && fileMetadata[ANSWER_FIELD_KEYS.TYPE]) {
          const fileType = fileMetadata[ANSWER_FIELD_KEYS.TYPE].toLowerCase();
          const isValidType = config[CONFIG_FIELD_KEYS.FILE_TYPE].some(
            (type: string) => fileType.includes(type.toLowerCase().replace('.', ''))
          );
          if (!isValidType) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FILE_INVALID_TYPE].replace('{0}', config[CONFIG_FIELD_KEYS.FILE_TYPE].join(', '));
          }
        }
      }
    }

    // Select / apicall validation: must be a non-empty string
    if (questionType === QUESTION_TYPES.SELECT || questionType === QUESTION_TYPES.APICALL) {
      if (typeof answerAsString !== 'string' || answerAsString.trim() === '') {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.SELECT_MUST_BE_VALID];
      }

      // For apicall, check if the selected value exists in lookup context
      if (questionType === QUESTION_TYPES.APICALL && lookupContext) {
        const category = config[CONFIG_FIELD_KEYS.CATEGORY];
        if (category) {
          const lookupOptions = lookupContext.get(category);
          if (lookupOptions && lookupOptions.length > 0) {
            // Check if the answer matches any valid option (by ID, key, or value)
            const answerValue = answerAsString.trim();
            const isValid = lookupOptions.some(
              (option) =>
                String(option.id) === answerValue ||
                option[ANSWER_FIELD_KEYS.KEY] === answerValue ||
                option[ANSWER_FIELD_KEYS.VALUE] === answerValue,
            );
            if (!isValid) {
              return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.SELECT_INVALID_OPTION];
            }
          }
        }
      }
    }

    // Multiselect validation: answer is sent as a stringified JSON array
    if (questionType === QUESTION_TYPES.MULTISELECT) {
      try {
        const parsed = JSON.parse(answerAsString);
        if (!Array.isArray(parsed)) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTISELECT_INVALID_ARRAY];
        }
        if (typeof config[CONFIG_FIELD_KEYS.MIN_SELECTIONS] === 'number' && parsed.length < config[CONFIG_FIELD_KEYS.MIN_SELECTIONS]) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTISELECT_MIN_SELECTIONS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MIN_SELECTIONS]));
        }
        if (typeof config[CONFIG_FIELD_KEYS.MAX_SELECTIONS] === 'number' && parsed.length > config[CONFIG_FIELD_KEYS.MAX_SELECTIONS]) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTISELECT_MAX_SELECTIONS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MAX_SELECTIONS]));
        }
      } catch {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTISELECT_INVALID_ARRAY];
      }
    }

    // Drag and drop: handles both preference ordering and file uploads
    if (questionType === QUESTION_TYPES.DRAGANDDROP) {
      // Handle mahatriaChoiceConfig - when mahatria choice is allocated, empty array is valid (id:17)
      const mahatriaConfig = config.mahatriaChoiceConfig;
      const allowsMahatriaChoice = mahatriaConfig?.allowMahatriaChoice === true;
      
      // Accept empty array encodings as valid (including %5B%5D for mahatria choice)
      if ([
        EMPTY_VALUES.EMPTY_ARRAY_JSON,
        EMPTY_VALUES.EMPTY_ARRAY_ENCODED,
        EMPTY_VALUES.EMPTY_STRING,
      ].includes(answerAsString.trim())) {
        // Empty is only valid if mahatria choice is allowed OR field is not required
        if (allowsMahatriaChoice || !config[CONFIG_FIELD_KEYS.IS_REQUIRED]) {
          return null;
        }
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.FIELD_IS_REQUIRED];
      }
      
      let decoded = answerAsString;
      try {
        // Try to decode URI component if it's encoded
        decoded = decodeURIComponent(answerAsString);
      } catch {}
      
      try {
        const parsed = JSON.parse(decoded);
        if (!Array.isArray(parsed)) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DRAG_DROP_INVALID_ARRAY];
        }

        // If fileConfig exists, validate as file upload drag-and-drop
        if (config[CONFIG_FIELD_KEYS.FILE_CONFIG] && parsed.length > 0) {
          const fileConfig = config[CONFIG_FIELD_KEYS.FILE_CONFIG];
          
          for (const file of parsed) {
            // Each file should be an object with metadata
            if (typeof file === 'object' && file !== null) {
              // Validate file format - use 'accepts' key not 'allowedFormats'
              const acceptsKey = fileConfig.accepts || fileConfig[CONFIG_FIELD_KEYS.ALLOWED_FORMATS];
              if (acceptsKey && Array.isArray(acceptsKey)) {
                if (file[ANSWER_FIELD_KEYS.TYPE]) {
                  const fileType = file[ANSWER_FIELD_KEYS.TYPE].toLowerCase();
                  const isValidFormat = acceptsKey.some(
                    (format: string) => fileType.includes(format.toLowerCase().replace('.', ''))
                  );
                  if (!isValidFormat) {
                    return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DRAG_DROP_FILE_FORMAT].replace('{0}', acceptsKey.join(', '));
                  }
                }
              }

              // Validate file size - support both maxSize and sizeLimit
              let maxSizeBytes: number | null = null;
              
              if (fileConfig.sizeLimit?.maxValue) {
                const unit = fileConfig.sizeLimit.unit?.toLowerCase() || FILE_SIZE_UNITS.MB_LOWER;
                maxSizeBytes = convertFileSizeToBytes(fileConfig.sizeLimit.maxValue, unit);
              } else if (fileConfig[CONFIG_FIELD_KEYS.MAX_SIZE]) {
                maxSizeBytes = convertFileSizeToBytes(fileConfig[CONFIG_FIELD_KEYS.MAX_SIZE]);
              }
              
              if (maxSizeBytes && file[ANSWER_FIELD_KEYS.SIZE]) {
                if (file[ANSWER_FIELD_KEYS.SIZE] > maxSizeBytes) {
                  const maxSizeMB = convertBytesToMB(maxSizeBytes);
                  return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DRAG_DROP_FILE_SIZE].replace('{0}', maxSizeMB);
                }
              }
            }
          }

          // Validate max number of files
          if (typeof fileConfig.maxFiles === 'number' && parsed.length > fileConfig.maxFiles) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DRAG_DROP_MAX_FILES].replace('{0}', String(fileConfig.maxFiles));
          }
        }
      } catch {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DRAG_DROP_INVALID_ARRAY];
      }
    }

    // Date validation with comprehensive config support
    if (questionType === QUESTION_TYPES.DATE || questionType === QUESTION_TYPES.DATEANDTIME) {
      const dateValue = new Date(answerAsString);
      if (isNaN(dateValue.getTime())) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_MUST_BE_VALID];
      }

      // Additional validation for dateandtime to ensure proper ISO 8601 format with time component
      if (questionType === QUESTION_TYPES.DATEANDTIME) {
        // Check if the string includes time component (T separator or space with time)
        const hasTimeComponent = answerAsString.includes('T') || VALIDATION_PATTERNS.DATETIME_WITH_TIME_COMPONENT.test(answerAsString);
        if (!hasTimeComponent) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATETIME_TIME_REQUIRED];
        }

        // Validate ISO 8601 format for dateandtime (YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss)
        if (!VALIDATION_PATTERNS.ISO_8601_DATETIME.test(answerAsString.trim())) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATETIME_INVALID_FORMAT];
        }
      }

      const dateTypeValidation = config[CONFIG_FIELD_KEYS.DATE_TYPE_VALIDATION] || DATE_VALIDATION_TYPES.DYNAMIC;

      // Static date range validation (between startDate and endDate)
      if (dateTypeValidation === DATE_VALIDATION_TYPES.STATIC) {
        if (config[CONFIG_FIELD_KEYS.START_DATE]) {
          const startDate = new Date(config[CONFIG_FIELD_KEYS.START_DATE]);
          if (dateValue < startDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_ON_OR_AFTER].replace('{0}', this.formatDate(startDate));
          }
        }
        if (config[CONFIG_FIELD_KEYS.END_DATE]) {
          const endDate = new Date(config[CONFIG_FIELD_KEYS.END_DATE]);
          if (dateValue > endDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_ON_OR_BEFORE].replace('{0}', this.formatDate(endDate));
          }
        }
      } 
      // Dynamic validation with past/future ranges (relative to today)
      else if (dateTypeValidation === DATE_VALIDATION_TYPES.DYNAMIC) {
        // Use IST (Asia/Kolkata) to determine today's date so early-morning IST requests
        // don't fail when the server's UTC date is still the previous day.
        const todayISTStr = new Date().toLocaleDateString('en-IN', { timeZone: 'Asia/Kolkata' });
        const today = new Date(todayISTStr + 'T00:00:00Z');

        // For the DOB question specifically, the relative age limits (e.g. min 16 / max 100 years)
        // must be evaluated as of the PROGRAM START DATE rather than today, so a registrant's
        // eligibility reflects their age when the program runs. Scoped to the dob binding key only —
        // every other dynamic date question keeps its existing behaviour untouched.
        // en-CA yields an ISO (YYYY-MM-DD) string that parses cleanly with the 'T00:00:00Z' suffix;
        // falls back to today (IST) when the program start date is absent.
        let referenceDate = today;
        if (question.bindingKey === REGISTRATION_BINDING_KEYS.DOB) {
          const dobBaseDate = context?.program?.startsAt ? new Date(context.program.startsAt) : new Date();
          if (!isNaN(dobBaseDate.getTime())) {
            const dobBaseISTStr = dobBaseDate.toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
            referenceDate = new Date(dobBaseISTStr + 'T00:00:00Z');
          }
        }

        const inputDateOnly = new Date(dateValue);
        inputDateOnly.setUTCHours(0, 0, 0, 0);

        // Validate past date constraints
        // Past dates: referenceDate - maxValue (oldest) to referenceDate - minValue (newest/most recent)
        if (config[CONFIG_FIELD_KEYS.PAST_VALIDATION] && config[CONFIG_FIELD_KEYS.PAST_VALIDATION][CONFIG_FIELD_KEYS.ENABLED]) {
          const { [CONFIG_FIELD_KEYS.UNIT]: unit, [CONFIG_FIELD_KEYS.MIN_VALUE]: minValue, [CONFIG_FIELD_KEYS.MAX_VALUE]: maxValue } = config[CONFIG_FIELD_KEYS.PAST_VALIDATION];

          // Most recent past date allowed (closest to the reference date)
          // minValue=0 means the reference date, minValue=1 means 1 unit before it
          const newestPastDate = this.addTimeUnit(new Date(referenceDate), unit, minValue != null ? -minValue : 0);
          newestPastDate.setUTCHours(0, 0, 0, 0);

          // Oldest past date allowed (farthest from the reference date)
          const oldestPastDate = maxValue != null ? this.addTimeUnit(new Date(referenceDate), unit, -maxValue) : null;
          if (oldestPastDate) {
            oldestPastDate.setUTCHours(0, 0, 0, 0);
          }

          // Date must be on or before the newest allowed date
          if (inputDateOnly > newestPastDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_PAST_MIN]
              .replace('{0}', String(minValue))
              .replace('{1}', unit);
          }

          // Date must be on or after the oldest allowed date
          if (oldestPastDate && inputDateOnly < oldestPastDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_PAST_MAX]
              .replace('{0}', String(maxValue))
              .replace('{1}', unit);
          }
        }

        // Validate future date constraints
        // Future dates: referenceDate + minValue (earliest) to referenceDate + maxValue (latest)
        if (config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION] && config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION][CONFIG_FIELD_KEYS.ENABLED]) {
          const { [CONFIG_FIELD_KEYS.UNIT]: unit, [CONFIG_FIELD_KEYS.MIN_VALUE]: minValue, [CONFIG_FIELD_KEYS.MAX_VALUE]: maxValue } = config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION];

          // Earliest future date allowed (closest to the reference date)
          const earliestFutureDate = this.addTimeUnit(new Date(referenceDate), unit, minValue != null ? minValue : 0);
          earliestFutureDate.setUTCHours(0, 0, 0, 0);

          // Latest future date allowed (farthest from the reference date)
          const latestFutureDate = maxValue != null ? this.addTimeUnit(new Date(referenceDate), unit, maxValue) : null;
          if (latestFutureDate) {
            latestFutureDate.setUTCHours(0, 0, 0, 0);
          }

          // Date must be on or after the earliest allowed date
          if (inputDateOnly < earliestFutureDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_FUTURE_MIN]
              .replace('{0}', String(minValue))
              .replace('{1}', unit);
          }

          // Date must be on or before the latest allowed date
          if (latestFutureDate && inputDateOnly > latestFutureDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_FUTURE_MAX]
              .replace('{0}', String(maxValue))
              .replace('{1}', unit);
          }
        }
      } 
      // Custom validation based on program field (e.g., registrationStartsAt)
      else if (dateTypeValidation === DATE_VALIDATION_TYPES.CUSTOM) {
        const pastValidation = config[CONFIG_FIELD_KEYS.PAST_VALIDATION];
        const futureValidation = config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION];
        
        // Get dateValidationField from the enabled validation
        let fieldName: string | null = null;
        if (pastValidation?.[CONFIG_FIELD_KEYS.ENABLED]) {
          fieldName = pastValidation[CONFIG_FIELD_KEYS.DATE_VALIDATION_FIELD];
        } else if (futureValidation?.[CONFIG_FIELD_KEYS.ENABLED]) {
          fieldName = futureValidation[CONFIG_FIELD_KEYS.DATE_VALIDATION_FIELD];
        }
        
        if (!fieldName) {
          this.logger.warn(VALIDATION_LOG_MESSAGES.WARN_CUSTOM_DATE_VALIDATION_FIELD_MISSING, {
            question: question.bindingKey,
          });
          return null;
        }
        
        // ONLY use the primary program context (not allocatedProgram fallbacks)
        const programContext = context?.program;
        
        if (!programContext) {
          this.logger.warn(VALIDATION_LOG_MESSAGES.WARN_CUSTOM_DATE_VALIDATION_CONTEXT_MISSING, {
            field: fieldName,
            question: question.bindingKey,
          });
          return null;
        }

        // Read ONLY from the specified field (e.g., registrationStartsAt)
        const baseFieldValue = programContext[fieldName];
        
        if (!baseFieldValue) {
          this.logger.warn(VALIDATION_LOG_MESSAGES.WARN_DATE_VALIDATION_FIELD_NOT_FOUND.replace('{0}', fieldName), {
            question: question.bindingKey,
            programId: programContext.id,
            availableFields: Object.keys(programContext),
          });
          // Field not found, skip validation
          return null;
        }

        const baseDate = new Date(baseFieldValue);
        if (isNaN(baseDate.getTime())) {
          this.logger.error(VALIDATION_LOG_MESSAGES.ERROR_INVALID_DATE_IN_FIELD.replace('{0}', fieldName).replace('{1}', baseFieldValue));
          return null;
        }

        // Normalize dates to start of day for accurate comparison
        baseDate.setHours(0, 0, 0, 0);
        const inputDateOnly = new Date(dateValue);
        inputDateOnly.setHours(0, 0, 0, 0);

        // Past validation: dates before baseDate
        if (config[CONFIG_FIELD_KEYS.PAST_VALIDATION] && config[CONFIG_FIELD_KEYS.PAST_VALIDATION][CONFIG_FIELD_KEYS.ENABLED]) {
          if (inputDateOnly < baseDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_ON_OR_BEFORE]
              .replace('{0}', this.formatDate(baseDate));
          }
        }

        // Future validation: dates after baseDate
        if (config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION] && config[CONFIG_FIELD_KEYS.FUTURE_VALIDATION][CONFIG_FIELD_KEYS.ENABLED]) {
          // Check if date is within future valid range
          if (inputDateOnly > baseDate) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.DATE_ON_OR_AFTER]
              .replace('{0}', this.formatDate(baseDate));
          }
        }
      }
    }

    // Year validation with config support
    if (questionType === QUESTION_TYPES.YEAR) {
      if (!VALIDATION_PATTERNS.YEAR_FORMAT.test(answerAsString)) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_INVALID_FORMAT];
      }

      const year = Number(answerAsString);
      
      // Guard against NaN - non-numeric input should fail
      if (isNaN(year)) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_INVALID_FORMAT];
      }
      
      const currentYear = new Date().getFullYear();

      // Validate yearOffset (minimum year allowed)
      if (config[CONFIG_FIELD_KEYS.YEAR_OFFSET]) {
        const minYear = config[CONFIG_FIELD_KEYS.YEAR_OFFSET];
        if (year < minYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_MIN_VALUE].replace('{0}', String(minYear));
        }
      }

      // Validate maxYear or maxYearOffset
      if (config[CONFIG_FIELD_KEYS.MAX_YEAR] === VALIDATION_SPECIAL_VALUES.CURRENT || config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET] === 0) {
        if (year > currentYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_CANNOT_BE_FUTURE];
        }
      } else if (typeof config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET] === 'number') {
        const maxYear = currentYear + config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET];
        if (year > maxYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_MAX_VALUE].replace('{0}', String(maxYear));
        }
      }
    }

    // Year range validation: supports 2024-2025, 2024/2025, 2024 - 2025, 2024 / 2025
    // Used for academic years - typically consecutive years like "2019-2020"
    if (questionType === QUESTION_TYPES.YEARRANGE) {
      const match = answerAsString.match(VALIDATION_PATTERNS.YEAR_RANGE_STRICT);
      
      if (!match) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_INVALID_FORMAT];
      }

      const startYear = Number(match[1]);
      const endYear = Number(match[2]);
      const currentYear = new Date().getFullYear();

      // Validate that start year is before or equal to end year
      if (startYear > endYear) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_START_AFTER_END];
      }
      
      // Enforce consecutive-year constraint for academic years (2019-2020 style)
      // Reject ranges like "2019-2022" which span multiple years
      if (endYear - startYear > 1) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_CONSECUTIVE];
      }

      // Validate minimum year (yearOffset)
      if (config[CONFIG_FIELD_KEYS.YEAR_OFFSET]) {
        const minYear = config[CONFIG_FIELD_KEYS.YEAR_OFFSET];
        if (startYear < minYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_START_MIN].replace('{0}', String(minYear));
        }
      }

      // Validate maximum year
      if (config[CONFIG_FIELD_KEYS.MAX_YEAR] === VALIDATION_SPECIAL_VALUES.CURRENT || config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET] === 0) {
        if (endYear > currentYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_END_FUTURE];
        }
      } else if (typeof config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET] === 'number') {
        const maxYear = currentYear + config[CONFIG_FIELD_KEYS.MAX_YEAR_OFFSET];
        if (endYear > maxYear) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_END_MAX].replace('{0}', String(maxYear));
        }
      }

      // Validate minValue/maxValue (range span validation)
      if (typeof config[CONFIG_FIELD_KEYS.MIN_VALUE] === 'number') {
        const span = endYear - startYear + 1;
        if (span < config[CONFIG_FIELD_KEYS.MIN_VALUE]) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_SPAN_MIN].replace('{0}', String(config[CONFIG_FIELD_KEYS.MIN_VALUE]));
        }
      }

      if (typeof config[CONFIG_FIELD_KEYS.MAX_VALUE] === 'number') {
        const span = endYear - startYear + 1;
        if (span > config[CONFIG_FIELD_KEYS.MAX_VALUE]) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.YEAR_RANGE_SPAN_MAX].replace('{0}', String(config[CONFIG_FIELD_KEYS.MAX_VALUE]));
        }
      }
    }

    // Address validation
    if (questionType === QUESTION_TYPES.ADDRESS) {
      if (typeof config[CONFIG_FIELD_KEYS.MIN_CHARACTER] === 'number' && answerAsString.length < config[CONFIG_FIELD_KEYS.MIN_CHARACTER]) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MIN_CHARACTERS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MIN_CHARACTER]));
      }
      if (typeof config[CONFIG_FIELD_KEYS.MAX_CHARACTERS] === 'number' && answerAsString.length > config[CONFIG_FIELD_KEYS.MAX_CHARACTERS]) {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MAX_CHARACTERS].replace('{0}', String(config[CONFIG_FIELD_KEYS.MAX_CHARACTERS]));
      }
      if (config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN]) {
        const addressRegex = new RegExp(config[CONFIG_FIELD_KEYS.VALIDATION_PATTERN]);
        if (!addressRegex.test(answerAsString)) {
          return config[CONFIG_FIELD_KEYS.PATTERN_ERROR_MSG] || ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.INVALID_FORMAT];
        }
      }
    }

    // Multi-question validation: answer is sent as a stringified JSON object
    if (questionType === QUESTION_TYPES.MULTIQUESTION) {
      try {
        const parsed = JSON.parse(answerAsString);
        if (typeof parsed !== 'object' || Array.isArray(parsed) || parsed === null) {
          return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTIQUESTION_INVALID_OBJECT];
        }
        // Validate sub-questions if config defines them
        const subQuestions: Array<{ 
          key: string; 
          label: string; 
          isRequired?: boolean;
          minCharacter?: number; 
          maxCharacters?: number;
        }> = config[CONFIG_FIELD_KEYS.QUESTIONS] || [];
        
        for (const sub of subQuestions) {
          const subValue = parsed[sub.key];
          const subStr = subValue !== undefined && subValue !== null ? String(subValue) : '';

          
          // Check if sub-question has its own isRequired, otherwise use parent's isRequired
          const isSubQuestionRequired = sub.isRequired !== undefined ? sub.isRequired : config[CONFIG_FIELD_KEYS.IS_REQUIRED];
          
          if (isSubQuestionRequired && (subStr === '' || subStr === null)) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTIQUESTION_SUBFIELD_REQUIRED].replace('{0}', sub.label);
          }
          if (typeof sub.minCharacter === 'number' && subStr.length < sub.minCharacter) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTIQUESTION_SUBFIELD_MIN_CHARS].replace('{0}', sub.label).replace('{1}', String(sub.minCharacter));
          }
          if (typeof sub.maxCharacters === 'number' && subStr.length > sub.maxCharacters) {
            return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTIQUESTION_SUBFIELD_MAX_CHARS].replace('{0}', sub.label).replace('{1}', String(sub.maxCharacters));
          }
        }
      } catch {
        return ERROR_MESSAGES[REGISTRATION_VALIDATION_MESSAGES.MULTIQUESTION_INVALID_STRINGIFIED];
      }
    }

    return null;
  }

  /**
   * Build a map of questionId to bindingKey from question mappings
   * This allows us to consistently use bindingKey for dependency resolution
   */
  private buildQuestionIdToBindingKeyMap(questionMappings: QuestionMapping[]): Map<number, string> {
    const map = new Map<number, string>();
    questionMappings.forEach((mapping) => {
      if (mapping.question.id && mapping.question.bindingKey) {
        map.set(mapping.question.id, mapping.question.bindingKey);
      }
    });
    return map;
  }

  /**
   * Get all subsection IDs recursively for a given parent section ID
   * @param parentSectionId - Parent section ID
   * @param questionMappings - All question mappings
   * @returns Array of subsection IDs (including nested subsections)
   */
  private getSubsections(parentSectionId: number, questionMappings: QuestionMapping[]): number[] {
    const subsectionIds: number[] = [];
    
    // Find all direct child sections
    const directChildren = questionMappings
      .filter(mapping => mapping.programQuestionFormSection?.parentSectionId === parentSectionId)
      .map(mapping => mapping.formSectionId)
      .filter((id, index, self) => id !== null && self.indexOf(id) === index) as number[];
    
    // Add direct children and recursively get their children
    directChildren.forEach(childId => {
      if (!subsectionIds.includes(childId)) {
        subsectionIds.push(childId);
        // Recursively get nested subsections
        const nestedSubsections = this.getSubsections(childId, questionMappings);
        nestedSubsections.forEach(nestedId => {
          if (!subsectionIds.includes(nestedId)) {
            subsectionIds.push(nestedId);
          }
        });
      }
    });
    
    return subsectionIds;
  }

  /**
   * Check if dependencies are satisfied and if they have disable type
   */
  private checkDependencyWithType(dependsOn: any[] | undefined, answerMap: Map<string, unknown>, context?: { program?: any; session?: any; registration?: any }): { satisfied: boolean; hasDisableType: boolean } {
    if (!Array.isArray(dependsOn) || dependsOn.length === 0) {
      return { satisfied: true, hasDisableType: false };
    }

    const hasDisableType = dependsOn.some((dep) => {
      if (dep?.[CONFIG_FIELD_KEYS.TYPE] === DEPENDENCY_VALUE_LABELS.DISABLE) {
        return true;
      }
      return false;
    });
    
    // Always evaluate dependencies, regardless of disable type
    // The disable type only affects what we do with the result
    const satisfied = this.isDependencySatisfied(dependsOn, answerMap, context);
    
    return { satisfied, hasDisableType };
  }

  /**
   * Check if dependencies are satisfied for a question
   * Uses bindingKey preferentially, resolving questionId to bindingKey when available
   */
  private isDependencySatisfied(dependsOn: any[] | undefined, answerMap: Map<string, unknown>, context?: { program?: any; session?: any; registration?: any }): boolean {
    if (!Array.isArray(dependsOn) || dependsOn.length === 0) {
      return true;
    }

    return dependsOn.some((condition) => {
      // ALWAYS use bindingKey for lookup - questionId is no longer supported
      const lookupKey = condition.questionBindingKey;
      
      if (!lookupKey) {
        this.logger.error('[VALIDATION] CRITICAL: dependsOn.questionBindingKey is missing. Dependencies must use questionBindingKey.', '', { condition });
        // Fail gracefully - dependency not satisfied if bindingKey missing
        return false;
      }
      
      const dependentValue = answerMap.get(lookupKey);
      
      let expectedValue = condition.value;
      const operator = condition.operator || COMPARISON_OPERATORS.EQUALS;
      
      // Log when dependent value is missing (could be from another section/stage)
      if ((dependentValue === undefined || dependentValue === null || dependentValue === '') && 
          operator !== COMPARISON_OPERATORS.NOT_EQUALS) {
        this.logger.debug(VALIDATION_LOG_MESSAGES.DEBUG_DEPENDENCY_NOT_SATISFIED, {
          dependentQuestionBindingKey: condition.questionBindingKey,
          operator,
          expectedValue,
          note: 'This question might be in a different section or workflow stage'
        });
      }
      
      // Handle age-range dependencies with transformedMaxValue/transformedMinValue (e.g., phoneNumberType)
      // These reference a DOB field and compute age ranges (e.g., elderMinAge, childMaxAge)
      // The question should only be shown if age falls within the specified range
      if (condition.transformedMaxValue || condition.transformedMinValue) {
        try {
          // Get DOB from answerMap using questionBindingKey only
          const dobValue = answerMap.get(condition.questionBindingKey);
          
          if (!dobValue || typeof dobValue !== 'string') {
            // DOB not provided yet, consider dependency not satisfied
            return false;
          }
          
          // Calculate age from DOB as of the program start date (falls back to today when startsAt is absent)
          const age = getAgeFromDOB(dobValue, context?.program?.startsAt);

          // Get age thresholds from program context
          const childMaxAge = context?.program?.childMaxAge;
          const elderMinAge = context?.program?.elderMinAge;
          
          // Determine if age falls within the conditional range
          // For phoneNumberType (id:94): shown only for children (age < childMaxAge)
          // The dependency is satisfied if person is a CHILD
          // Using < instead of <= means: if childMaxAge=17, only those aged 0-16 (not yet completed 17 years) are children
          if (childMaxAge !== null && childMaxAge !== undefined && age < childMaxAge) {
            return true; // Person is a child, show the field
          }
          if (elderMinAge !== null && elderMinAge !== undefined && age >= elderMinAge) {
            return true; // Person is an elder, show the field
          }
          
          return false; // Person not in target age range
        } catch (error) {
          this.logger.error(VALIDATION_LOG_MESSAGES.ERROR_EVALUATING_AGE_DEPENDENCY, error);
          return false; // Assume not satisfied on error
        }
      }

      // Handle notNull special case (value may be the bare string or wrapped in an array)
      const expectsNotNull =
        expectedValue === VALIDATION_SPECIAL_VALUES.NOT_NULL ||
        (Array.isArray(expectedValue) && expectedValue.includes(VALIDATION_SPECIAL_VALUES.NOT_NULL));
      if (expectsNotNull) {
        return dependentValue !== null && dependentValue !== undefined && dependentValue !== '';
      }
      // If dependent value missing, dependency not satisfied (except for not_equals)
      if (operator !== COMPARISON_OPERATORS.NOT_EQUALS) {
        if (dependentValue === undefined || dependentValue === null || dependentValue === '') {
          return false;
        }
      }

      const normalizedDependent = typeof dependentValue === 'string' ? dependentValue.trim() : dependentValue;

      switch (operator) {
        case COMPARISON_OPERATORS.EQUALS:
          if (Array.isArray(expectedValue)) {
            return expectedValue.some((v) => this.compareValues(normalizedDependent, v));
          }
          return this.compareValues(normalizedDependent, expectedValue);
        case COMPARISON_OPERATORS.NOT_EQUALS:
          // For not_equals, undefined/null/empty is different from expected
          if (dependentValue === undefined || dependentValue === null || dependentValue === '') {
            if (Array.isArray(expectedValue)) {
              return expectedValue.some((v) => v !== '' && v !== null && v !== undefined);
            }
            return expectedValue !== '' && expectedValue !== null && expectedValue !== undefined;
          }
          if (Array.isArray(expectedValue)) {
            return !expectedValue.some((v) => this.compareValues(normalizedDependent, v));
          }
          return !this.compareValues(normalizedDependent, expectedValue);
        case COMPARISON_OPERATORS.GREATER_THAN:
          return this.compareNumericMaybeArray(normalizedDependent, expectedValue, 'gt');
        case COMPARISON_OPERATORS.GREATER_THAN_OR_EQUAL:
          return this.compareNumericMaybeArray(normalizedDependent, expectedValue, 'gte');
        case COMPARISON_OPERATORS.LESS_THAN:
          return this.compareNumericMaybeArray(normalizedDependent, expectedValue, 'lt');
        case COMPARISON_OPERATORS.LESS_THAN_OR_EQUAL:
          return this.compareNumericMaybeArray(normalizedDependent, expectedValue, 'lte');
        default:
          return this.compareValues(normalizedDependent, expectedValue);
      }
    });
  }
  
  /**
   * Numeric/year comparison that mirrors the `equals` operator's array handling.
   * A dependency value may be a bare value or an array of thresholds; the dependency is
   * satisfied if ANY element matches. Passing an array straight to compareNumericOrYearRange
   * would coerce it via Number() (NaN for multi-element arrays) and silently fail the comparison.
   */
  private compareNumericMaybeArray(actual: unknown, expected: unknown, operator: 'gt' | 'gte' | 'lt' | 'lte'): boolean {
    if (Array.isArray(expected)) {
      return expected.some((v) => this.compareNumericOrYearRange(actual, v, operator));
    }
    return this.compareNumericOrYearRange(actual, expected, operator);
  }

  /**
   * Compare numeric or year range values (e.g., "2019-2020")
   */
  private compareNumericOrYearRange(actual: unknown, expected: unknown, operator: 'gt' | 'gte' | 'lt' | 'lte'): boolean {
    // Try year range comparison first if both are strings with year range format
    if (typeof actual === 'string' && typeof expected === 'string') {
      const actualYearMatch = actual.match(VALIDATION_PATTERNS.YEAR_RANGE_MATCH);
      const expectedYearMatch = expected.match(VALIDATION_PATTERNS.YEAR_RANGE_MATCH);
      
      if (actualYearMatch && expectedYearMatch) {
        // Compare based on start year of the range
        const actualStartYear = parseInt(actualYearMatch[1], 10);
        const expectedStartYear = parseInt(expectedYearMatch[1], 10);
        
        switch (operator) {
          case 'gt': return actualStartYear > expectedStartYear;
          case 'gte': return actualStartYear >= expectedStartYear;
          case 'lt': return actualStartYear < expectedStartYear;
          case 'lte': return actualStartYear <= expectedStartYear;
        }
      }
    }
    
    // Fallback to numeric comparison
    const actualNum = Number(actual);
    const expectedNum = Number(expected);
    
    if (isNaN(actualNum) || isNaN(expectedNum)) {
      // If conversion fails, comparisons fail (return false)
      return false;
    }
    
    switch (operator) {
      case 'gt': return actualNum > expectedNum;
      case 'gte': return actualNum >= expectedNum;
      case 'lt': return actualNum < expectedNum;
      case 'lte': return actualNum <= expectedNum;
      default: return false;
    }
  }

  /**
   * Compare two values (case-insensitive for strings)
   */
  private compareValues(actual: unknown, expected: unknown): boolean {
    // Handle numeric string comparisons (e.g., "0" === 0, "5" === 5)
    // This is common when comparing user input strings with config numbers
    if (typeof actual === 'string' && typeof expected === 'number') {
      const actualNum = Number(actual);
      return !isNaN(actualNum) && actualNum === expected;
    }
    if (typeof actual === 'number' && typeof expected === 'string') {
      const expectedNum = Number(expected);
      return !isNaN(expectedNum) && actual === expectedNum;
    }
    
    // Handle string comparisons (case-insensitive)
    if (typeof actual === 'string' && typeof expected === 'string') {
      return actual.toLowerCase() === expected.toLowerCase();
    }

    // Handle direct equality for other types
    return actual === expected;
  }

  /**
   * Add or subtract time units from a date
   */
  private addTimeUnit(date: Date, unit: string, value: number): Date {
    const result = new Date(date);
    const normalizedUnit = unit.toLowerCase();

    switch (normalizedUnit) {
      case TIME_UNITS.YEAR:
      case TIME_UNITS.YEARS:
        result.setUTCFullYear(result.getUTCFullYear() + value);
        break;
      case TIME_UNITS.MONTH:
      case TIME_UNITS.MONTHS:
        result.setUTCMonth(result.getUTCMonth() + value);
        break;
      case TIME_UNITS.DAY:
      case TIME_UNITS.DAYS:
        result.setUTCDate(result.getUTCDate() + value);
        break;
      default:
        this.logger.warn(`Unknown time unit: ${unit}`);
    }
    return result;
  }

  /**
   * Format date for error messages (DD/MM/YYYY format)
   */
  private formatDate(date: Date): string {
    const day = String(date.getDate()).padStart(2, '0');
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const year = date.getFullYear();
    return `${day}/${month}/${year}`;
  }
}
