import { DataTypes, Op, Sequelize } from 'sequelize';

import {
  CHARTING_DATA_ELEMENT_IDS,
  CHARTING_SURVEY_TYPES,
  PROGRAM_DATA_ELEMENT_TYPES,
  SYNC_DIRECTIONS,
  VISIBILITY_STATUSES,
} from '@tamanu/constants';
import { InvalidOperationError, UsageError } from '@tamanu/errors';
import { runCalculations } from '@tamanu/shared/utils/calculations';
import {
  getActiveActionComponents,
  getResultValue,
  getStringValue,
} from '@tamanu/shared/utils/fields';
import { getPatientDataDbLocation } from '@tamanu/shared/utils/getPatientDataDbLocation';
import { getCurrentDateTimeString } from '@tamanu/utils/dateTime';
import { safeJsonParse } from '@tamanu/utils/safeJsonParse';
import { buildEncounterLinkedLookupFilter } from '../sync/buildEncounterLinkedLookupFilter';
import { buildEncounterLinkedSyncFilter } from '../sync/buildEncounterLinkedSyncFilter';
import { dateTimeType, type InitOptions, type Models } from '../types/model';
import type { Encounter } from './Encounter';
import type { Facility } from './Facility';
import { Model } from './Model';
import type { Patient } from './Patient';
import type { PatientIssue } from './PatientIssue';
import type { ProgramDataElement } from './ProgramDataElement';
import type { Survey } from './Survey';
import type { User } from './User';

/** @internal Use {@link SurveyResponse.createPatientIssues} instead. */
async function _createPatientIssues(
  models: Models,
  questions: any[],
  patientId: Patient['id'],
  recordedDate?: PatientIssue['recordedDate'],
) {
  if (!models.PatientIssue.sequelize.isInsideTransaction()) {
    throw new UsageError('createPatientIssues must always run inside a transaction!');
  }
  const issueQuestions = questions.filter(
    q => q.dataElement.type === PROGRAM_DATA_ELEMENT_TYPES.PATIENT_ISSUE,
  );
  for (const question of issueQuestions) {
    const config = safeJsonParse(question.config) ?? {};
    if (!config.issueNote || !config.issueType) {
      throw new InvalidOperationError(
        `Ill-configured PatientIssue with config: ${question.config}`,
      );
    }

    /** NB: Initialising sans-`recordedDate` to query for duplicate. Attaches later if needed. */
    const issueData: Partial<PatientIssue> = {
      patientId,
      type: config.issueType,
      note: config.issueNote,
    };

    const existing = await models.PatientIssue.findOne({
      attributes: ['id'], // Arbitrary projection, just checking existence
      where: issueData,
    });

    if (existing !== null) continue; // Prevent duplicates when program responses are edited

    if (recordedDate) issueData.recordedDate = recordedDate;
    await models.PatientIssue.create(issueData);
  }
}

/**
 * Returns in the format:
 * {
 *  Patient: { key1: 'value1' },
 *  PatientAdditionalData: { key1: 'value1' },
 * }
 */
const getFieldsToWrite = async (
  models: Models,
  questions: any[],
  answers: Record<ProgramDataElement['id'], any>,
) => {
  const recordValuesByModel: Record<string, Record<string, any>> = {};

  const patientDataQuestions = questions.filter(
    q => q.dataElement.type === PROGRAM_DATA_ELEMENT_TYPES.PATIENT_DATA,
  );
  for (const question of patientDataQuestions) {
    const { dataElement, config: configString } = question;
    const config = JSON.parse(configString) || {};

    if (!config.writeToPatient) {
      // this is just a question that's reading patient data, not writing it
      continue;
    }

    const { fieldName: configFieldName } = config.writeToPatient || {};
    if (!configFieldName) {
      throw new Error('No fieldName defined for writeToPatient config');
    }
    const value = answers[dataElement.id];

    const { modelName, fieldName } = await getPatientDataDbLocation(configFieldName, models);

    if (!modelName) {
      throw new Error(`Unknown fieldName: ${configFieldName}`);
    }
    recordValuesByModel[modelName] ??= {};
    recordValuesByModel[modelName][fieldName] = value;
  }
  return recordValuesByModel;
};

/**
 * @internal Use {@link SurveyResponse.writeToPatientFields} instead
 * @privateRemarks DUPLICATED IN mobile/App/models/SurveyResponse.ts. Please keep in sync.
 */
async function _writeToPatientFields(
  models: Models,
  facilityId: Facility['id'],
  questions: any[],
  answers: Record<ProgramDataElement['id'], any>,
  patientId: Patient['id'],
  surveyId: Survey['id'],
  userId: User['id'],
  submittedTime: string,
) {
  const valuesByModel = await getFieldsToWrite(models, questions, answers);

  if (valuesByModel.Patient) {
    const patient = await models.Patient.findByPk(patientId);
    await patient?.update(valuesByModel.Patient);
  }

  if (valuesByModel.PatientFieldValue) {
    const patient = await models.Patient.findByPk(patientId);
    await patient?.writeFieldValues(valuesByModel.PatientFieldValue);
  }

  if (valuesByModel.PatientAdditionalData) {
    const pad = await models.PatientAdditionalData.getOrCreateForPatient(patientId);
    await pad.update(valuesByModel.PatientAdditionalData);
  }

  if (valuesByModel.PatientProgramRegistration) {
    const survey = await models.Survey.findByPk(surveyId);
    const programRegistryDetail = await models.ProgramRegistry.findOne({
      where: { programId: survey?.programId, visibilityStatus: VISIBILITY_STATUSES.CURRENT },
    });
    if (!programRegistryDetail?.id) {
      throw new Error('No program registry configured for the current form');
    }

    // Check if a record already exists with the given patientId and programRegistryId
    const existingRegistration = await models.PatientProgramRegistration.findOne({
      where: {
        patientId,
        programRegistryId: programRegistryDetail.id,
      },
    });

    const registrationData = {
      patientId,
      programRegistryId: programRegistryDetail.id,
      ...valuesByModel.PatientProgramRegistration,
      registeringFacilityId:
        valuesByModel.PatientProgramRegistration.registeringFacilityId || facilityId,
      clinicianId: valuesByModel.PatientProgramRegistration.clinicianId || userId,
    };

    if (existingRegistration) {
      await existingRegistration.update(registrationData);
    } else {
      await models.PatientProgramRegistration.create({
        date: submittedTime,
        ...registrationData,
      });
    }
  }
}

export class SurveyResponse extends Model {
  declare id: string;
  declare startTime?: string;
  declare endTime?: string;
  declare result?: number;
  declare resultText?: string;
  /**
   * Not to be confused with metadata attribute `updated_at`. `edited_time` is non-NULL if and only if
   * this survey response has been meaningfully edited by a user via `PATCH /surveyResponse`.
   */
  declare editedTime?: string;
  declare notified?: boolean;
  declare metadata?: Record<string, any>;
  declare userId?: User['id'];
  declare surveyId?: Survey['id'];
  declare encounterId?: Encounter['id'];

  static initModel({ primaryKey, ...options }: InitOptions) {
    super.init(
      {
        id: primaryKey,
        startTime: dateTimeType('startTime', { allowNull: true }),
        endTime: dateTimeType('endTime', { allowNull: true }),
        result: { type: DataTypes.FLOAT, allowNull: true },
        resultText: { type: DataTypes.TEXT, allowNull: true },
        editedTime: dateTimeType('editedTime', { allowNull: true }),
        notified: { type: DataTypes.BOOLEAN, allowNull: true }, // null is not notified, false is notified but not yet processed, true is processed
        metadata: { type: DataTypes.JSONB, allowNull: true },
      },
      {
        ...options,
        syncDirection: SYNC_DIRECTIONS.BIDIRECTIONAL,
      },
    );
  }

  static initRelations(models: Models) {
    this.belongsTo(models.User, {
      foreignKey: 'userId',
      as: 'user',
    });
    this.belongsTo(models.Survey, {
      foreignKey: 'surveyId',
      as: 'survey',
    });

    this.belongsTo(models.Encounter, {
      foreignKey: 'encounterId',
      as: 'encounter',
    });

    this.hasMany(models.SurveyResponseAnswer, {
      foreignKey: 'responseId',
      as: 'answers',
    });

    this.hasOne(models.Referral, {
      foreignKey: 'surveyResponseId',
      as: 'referral',
    });
  }

  static buildPatientSyncFilter(patientCount: number, markedForSyncPatientsTable: string) {
    if (patientCount === 0) {
      return null;
    }
    return buildEncounterLinkedSyncFilter(
      [this.tableName, 'encounters'],
      markedForSyncPatientsTable,
    );
  }

  static async buildSyncLookupQueryDetails() {
    return buildEncounterLinkedLookupFilter(this);
  }

  static async getSurveyEncounter({
    encounterId,
    patientId,
    forceNewEncounter,
    reasonForEncounter,
    answers,
    ...responseData
  }: {
    encounterId: string | null;
    patientId: string;
    forceNewEncounter: boolean;
    reasonForEncounter: string;
    answers?: Record<string, any>;
    [key: string]: any;
  }) {
    if (!this.sequelize.isInsideTransaction()) {
      throw new Error('SurveyResponse.getSurveyEncounter must always run inside a transaction!');
    }

    const { Encounter } = this.sequelize.models;

    if (encounterId) {
      return Encounter.findByPk(encounterId);
    }

    if (!patientId) {
      throw new InvalidOperationError(
        'A survey response must have an encounter or patient ID attached',
      );
    }

    // Extract date - chart entries have dateRecorded field, complex chart instances have complexChartDate
    const dateRecordedValue =
      answers?.[CHARTING_DATA_ELEMENT_IDS.dateRecorded] ||
      answers?.[CHARTING_DATA_ELEMENT_IDS.complexChartDate];

    if (!forceNewEncounter) {
      // First, check for open encounter (active encounter takes precedence)
      const openEncounter = await Encounter.findOne({
        where: {
          patientId,
          endDate: null,
        },
      });
      if (openEncounter) {
        return openEncounter;
      }

      // Then, check for existing form response encounter on the same date
      const recordedDate = dateRecordedValue || responseData.startTime || null;

      if (recordedDate) {
        const whereConditions = {
          patientId,
          encounterType: 'surveyResponse',
          [Op.and]: [
            Sequelize.where(
              Sequelize.fn('DATE', Sequelize.col('start_date')),
              Sequelize.fn('DATE', recordedDate),
            ),
          ],
          ...(responseData.departmentId && { departmentId: responseData.departmentId }),
          ...(responseData.locationId && { locationId: responseData.locationId }),
        };

        const existingFormResponseEncounter = await Encounter.findOne({
          where: whereConditions,
        });

        if (existingFormResponseEncounter) {
          return existingFormResponseEncounter;
        }
      }
    }

    const { departmentId, examinerId, userId, locationId } = responseData;

    const encounterStartDate =
      dateRecordedValue || responseData.startTime || getCurrentDateTimeString();
    const encounterEndDate =
      dateRecordedValue || responseData.endTime || getCurrentDateTimeString();

    // need to create a new encounter with examiner set as the user who submitted the survey.
    const newEncounter = await Encounter.create({
      patientId,
      encounterType: 'surveyResponse',
      reasonForEncounter,
      departmentId,
      examinerId: examinerId || userId,
      locationId,
      // Survey responses will usually have a startTime and endTime and we prefer to use that
      // for the encounter to ensure the times are set in the browser timezone
      startDate: encounterStartDate,
      actorId: userId,
    });

    return newEncounter.update({
      endDate: encounterEndDate,
      systemNote: 'Automatically discharged',
      discharge: {
        note: 'Automatically discharged after survey completion',
      },
    });
  }

  static async createWithAnswers(data: {
    answers: any[];
    surveyId: string;
    patientId: string;
    encounterId: string | null;
    forceNewEncounter: boolean;
    facilityId: string;
    [key: string]: any;
  }) {
    if (!this.sequelize.isInsideTransaction()) {
      throw new Error('SurveyResponse.createWithAnswers must always run inside a transaction!');
    }
    const { models } = this.sequelize;
    const {
      answers,
      surveyId,
      patientId,
      encounterId,
      forceNewEncounter,
      facilityId,
      ...responseData
    } = data;

    // ensure survey exists
    const survey = await models.Survey.findByPk(surveyId);
    if (!survey) {
      throw new InvalidOperationError(`Invalid survey ID: ${surveyId}`);
    }

    // figure out if its a vital survey response
    const vitalsSurvey = await models.Survey.getVitalsSurvey();
    // use optional chaining because vitals survey might not exist
    const isVitalSurvey = surveyId === vitalsSurvey?.id;

    const questions = await models.SurveyScreenComponent.getComponentsForSurvey(surveyId);

    const calculatedAnswers = runCalculations(questions, answers);
    const finalAnswers = {
      ...answers,
      ...calculatedAnswers,
    };

    // Determine if this is a chart entry or form response
    const isChartEntry = CHARTING_SURVEY_TYPES.includes(survey.surveyType);
    const reasonForEncounter = isChartEntry ? 'Chart entry' : 'Form response';

    const encounter = await this.getSurveyEncounter({
      encounterId,
      patientId,
      forceNewEncounter,
      reasonForEncounter,
      answers: finalAnswers,
      ...responseData,
    });
    const { result, resultText } = getResultValue(questions, answers, {
      encounterType: encounter.encounterType,
    });
    const record = await SurveyResponse.create({
      patientId: patientId ?? encounter.patientId,
      surveyId,
      encounterId: encounter.id,
      result,
      resultText,
      // put responseData last to allow for user to override
      // resultText by including it in the data
      // this is used by reports test where the resultText
      // is included in the payload
      ...responseData,
      notified: responseData?.endTime && survey?.notifiable ? false : null,
    });

    const findDataElement = (id: string) => {
      const component = questions.find(c => c.dataElement.id === id);
      if (!component) return null;
      return component.dataElement;
    };

    // create answer records
    for (const a of Object.entries(finalAnswers)) {
      const [dataElementId, value] = a;
      const dataElement = findDataElement(dataElementId);
      if (!dataElement) {
        throw new Error(`no data element for question: ${dataElementId}`);
      }
      const body = await SurveyResponse.getBodyForAnswer(dataElement.type, value, models);
      // Don't create null answers
      if (body === null) {
        continue;
      }

      const answer = await models.SurveyResponseAnswer.create({
        dataElementId: dataElement.id,
        body,
        responseId: record.id,
      });
      if (!isVitalSurvey || body === '') continue;
      // Generate initial vital log
      await models.VitalLog.create({
        date: record.endTime || getCurrentDateTimeString(),
        newValue: body,
        recordedById: responseData.userId,
        answerId: answer.id,
      });
    }

    await SurveyResponse.handleSurveyResponseActions(
      models,
      facilityId,
      questions,
      finalAnswers,
      encounter.patientId,
      surveyId,
      responseData.userId,
      responseData.endTime,
    );

    return record;
  }

  /**
   * Special case for answers that depend on creating a new record in the database and store the ID
   * of the new record in the answer body. Currently only used for photos.
   */
  static async getBodyForAnswer(
    dataElementType: ProgramDataElement['type'],
    value: any,
    models: Models,
  ) {
    if (dataElementType === PROGRAM_DATA_ELEMENT_TYPES.PHOTO && value) {
      // If the client already provided an attachment ID, keep it as-is
      if (typeof value === 'string') return value;

      const { size, data } = value as unknown as { size: number; data: string };
      const { id: attachmentId } = await models.Attachment.create(
        models.Attachment.sanitizeForDatabase({ data, size, type: 'image/jpeg' }),
      );

      return attachmentId; // Store attachment ID as answer body
    }

    return getStringValue(dataElementType, value);
  }

  static async createPatientIssues(...args: Parameters<typeof _createPatientIssues>) {
    return await _createPatientIssues(...args);
  }

  static async writeToPatientFields(...args: Parameters<typeof _writeToPatientFields>) {
    return await _writeToPatientFields(...args);
  }

  static async handleSurveyResponseActions(
    models: Models,
    facilityId: Facility['id'],
    questions: any[],
    answers: Record<ProgramDataElement['id'], any>,
    patientId: Patient['id'],
    surveyId: Survey['id'],
    userId: User['id'],
    submittedTime: string,
  ) {
    const activeQuestions = getActiveActionComponents(questions, answers);
    await SurveyResponse.createPatientIssues(models, activeQuestions, patientId, submittedTime);
    await SurveyResponse.writeToPatientFields(
      models,
      facilityId,
      activeQuestions,
      answers,
      patientId,
      surveyId,
      userId,
      submittedTime,
    );
  }
}
