import { Realm } from "@realm/react";
import { Alert } from "react-native";
import { getNowISO } from "sharedHelpers/dateAndTime";
import { log } from "sharedHelpers/logger";
import readExifFromMultiplePhotos from "sharedHelpers/parseExif";
import safeRealmWrite from "sharedHelpers/safeRealmWrite";
import * as uuid from "uuid";

import Application from "./Application";
import Comment from "./Comment";
import Identification from "./Identification";
import ObservationPhoto from "./ObservationPhoto";
import ObservationSound from "./ObservationSound";
import Taxon from "./Taxon";
import User from "./User";
import Vote from "./Vote";

export const GEOPRIVACY_OPEN = "open";
export const GEOPRIVACY_OBSCURED = "obscured";
export const GEOPRIVACY_PRIVATE = "private";

const logger = log.extend( "index.js" );

// noting that methods like .toJSON( ) are only accessible when the model
// class is extended with Realm.Object per this issue:
// https://github.com/realm/realm-js/issues/3600#issuecomment-785828614
class Observation extends Realm.Object {
  static PROJECT_FIELDS = {
    id: true,
    icon: true,
    title: true,
    project_type: true,
  };

  static FIELDS = {
    application: Application.APPLICATION_FIELDS,
    captive: true,
    comments: Comment.COMMENT_FIELDS,
    created_at: true,
    description: true,
    geojson: true,
    geoprivacy: true,
    id: true,
    identifications: Identification.ID_FIELDS,
    latitude: true,
    license_code: true,
    location: true,
    longitude: true,
    obscured: true,
    observation_photos: ObservationPhoto.OBSERVATION_PHOTOS_FIELDS,
    observed_on: true,
    place_guess: true,
    quality_grade: true,
    observation_sounds: ObservationSound.OBSERVATION_SOUNDS_FIELDS,
    observed_time_zone: true,
    taxon: Taxon.TAXON_FIELDS,
    taxon_geoprivacy: true,
    time_observed_at: true,
    user: User && {
      ...User.FIELDS,
      preferences: {
        prefers_community_taxa: true,
      },
    },
    updated_at: true,
    viewer_trusted_by_observer: true,
    votes: Vote.VOTE_FIELDS,
    private_geojson: true,
    private_location: true,
    private_place_guess: true,
    project_ids: true,
    project_observations: {
      project: Observation.PROJECT_FIELDS,
    },
    non_traditional_projects: {
      project: Observation.PROJECT_FIELDS,
    },
    positional_accuracy: true,
    preferences: {
      prefers_community_taxon: true,
    },
  };

  static DEFAULT_MODE_LIST_FIELDS = {
    created_at: true,
    id: true, // needed to get next page in infinite queries
    observation_photos: {
      id: true,
      photo: {
        id: true,
        url: true,
      },
      uuid: true,
    },
    observation_sounds: {
      uuid: true,
    },
    quality_grade: true,
    taxon: {
      id: true,
      name: true,
      preferred_common_name: true,
      // rank and rank_level are needed to italicize scientific names
      rank: true,
      rank_level: true,
    },
    time_observed_at: true,
    uuid: true,
  };

  static ADVANCED_MODE_LIST_FIELDS = {
    ...Observation.DEFAULT_MODE_LIST_FIELDS,
    identifications: {
      uuid: true,
      current: true,
    },
    comments: {
      uuid: true,
    },
    geoprivacy: true,
    id: true,
    latitude: true,
    longitude: true,
    obscured: true,
    observed_on: true,
    observed_time_zone: true,
    place_guess: true,
    private_place_guess: true,
    taxon_geoprivacy: true,
  };

  static async new( obs ) {
    return {
      ...obs,
      captive_flag: false,
      geoprivacy: GEOPRIVACY_OPEN,
      owners_identification_from_vision: false,
      observed_on: obs?.observed_on,
      observed_on_string: obs
        ? obs?.observed_on_string
        : getNowISO( ),
      quality_grade: "needs_id",
      needs_sync: true,
      uuid: uuid.v4( ),
    };
  }

  static async createObsWithSoundPath( soundPath ) {
    const observation = await Observation.new( );
    const sound = await ObservationSound.new( soundPath );
    observation.observationSounds = [sound];
    return observation;
  }

  static upsertRemoteObservations( remoteObservations, realm, options = {} ) {
    if ( !remoteObservations ) return;
    if ( remoteObservations.length === 0 ) return;
    const obsToUpsert = options.force
      ? remoteObservations
      : remoteObservations.filter( obs => !Observation.isUnsyncedObservation( realm, obs ) );
    // const msg = obsToUpsert.map( remoteObservation => {
    //   const obsPhotoUUIDs = remoteObservation.observation_photos?.map( op => op.uuid );
    //   return `obs ${remoteObservation.uuid}, ops: ${obsPhotoUUIDs}`;
    // } );
    // Trying to debug disappearing photos
    safeRealmWrite( realm, ( ) => {
      obsToUpsert.forEach( remoteObservation => {
        const obsMappedForRealm = Observation.mapApiToRealm( remoteObservation, realm );
        realm.create(
          "Observation",
          obsMappedForRealm,
          "modified",
        );
      } );
    }, "upserting remote observations in Observation" );
  }

  static mapApiToRealm( obs, realm = null ) {
    if ( !obs ) return obs;
    const existingObs = realm?.objectForPrimaryKey( "Observation", obs.uuid );
    const taxon = obs.taxon
      ? Taxon.mapApiToRealm( obs.taxon, realm )
      : null;
    const observationPhotos = (
      obs.observation_photos || obs.observationPhotos || []
    ).map( obsPhoto => {
      const mappedObsPhoto = ObservationPhoto.mapApiToRealm( obsPhoto, realm );
      const existingObsPhoto = existingObs?.observationPhotos?.find(
        op => op.uuid === obsPhoto.uuid,
      );
      if ( !existingObsPhoto ) {
        mappedObsPhoto._created_at = new Date( );
        mappedObsPhoto.photo._created_at = new Date( );
      }
      return mappedObsPhoto;
    } );

    const observationSounds = (
      obs.observation_sounds || obs.observationSounds || []
    ).map( obsSound => {
      const mappedObsSound = ObservationSound.mapApiToRealm( obsSound, realm );
      const existingObsSound = existingObs?.observationSounds?.find(
        os => os.uuid === obsSound.uuid,
      );
      if ( !existingObsSound ) {
        mappedObsSound._created_at = new Date( );
        mappedObsSound.sound._created_at = new Date( );
      }
      return mappedObsSound;
    } );

    const localObs = {
      ...obs,
      _synced_at: new Date( ),
      // obs detail on web says geojson coords are preferred over lat/long
      // https://github.com/inaturalist/inaturalist/blob/df6572008f60845b8ef5972a92a9afbde6f67829/app/webpack/observations/show/ducks/observation.js#L145
      latitude: obs.geojson && obs.geojson.coordinates && obs.geojson.coordinates[1],
      longitude: obs.geojson && obs.geojson.coordinates && obs.geojson.coordinates[0],
      privateLatitude: obs.private_geojson && obs.private_geojson.coordinates
                      && obs.private_geojson.coordinates[1],
      privateLongitude: obs.private_geojson && obs.private_geojson.coordinates
                      && obs.private_geojson.coordinates[0],
      observationPhotos,
      observationSounds,
      prefers_community_taxon: obs.preferences?.prefers_community_taxon,
      taxon,
    };

    if ( localObs.user ) {
      localObs.user.prefers_community_taxa = (
        localObs.user.prefers_community_taxa
        || localObs.user.preferences?.prefers_community_taxa
      );
    }

    if ( !existingObs ) {
      localObs._created_at = new Date( localObs.created_at );
      if ( isNaN( localObs._created_at ) ) {
        localObs._created_at = new Date( );
      }
    }
    return localObs;
  }

  static async saveLocalObservationForUpload( obs, realm ) {
    // make sure local observations have user details for ObsDetail
    const currentUser = User.currentUser( realm );
    if ( currentUser ) {
      obs.user = currentUser;
    }

    const timestamps = {
      _updated_at: new Date( ),
    };

    const existingObservation = realm.objectForPrimaryKey( "Observation", obs.uuid );

    if ( !existingObservation ) {
      timestamps._created_at = new Date( );
      timestamps._synced_at = null;
    }

    const addTimestampsToEvidence = evidence => ( evidence
      ? evidence.map( record => ( {
        ...record,
        ...timestamps,
      } ) )
      : evidence );

    const taxon = obs.taxon || null;
    const observationPhotos = addTimestampsToEvidence( obs.observationPhotos );
    const observationSounds = addTimestampsToEvidence( obs.observationSounds );

    const obsToSave = {
      // just ...obs causes problems when obs is a realm object
      // ...obs.toJSON( ),
      ...obs,
      ...timestamps,
      needs_sync: true,
      taxon,
      observationPhotos,
      observationSounds,
    };

    safeRealmWrite( realm, ( ) => {
      // using 'modified' here for the case where a new observation has the same Taxon
      // as a previous observation; otherwise, realm will error out
      // also using modified for updating observations which were already saved locally
      realm.create( "Observation", obsToSave, "modified" );
    }, "saving local observation for upload in Observation" );
    return realm.objectForPrimaryKey( "Observation", obs.uuid );
  }

  static mapObservationForUpload( obs ) {
    return {
      captive_flag: obs.captive_flag,
      description: obs.description,
      geoprivacy: obs.geoprivacy,
      latitude: obs.latitude,
      longitude: obs.longitude,
      observed_on_string: obs.observed_on_string,
      owners_identification_from_vision: obs.owners_identification_from_vision,
      place_guess: obs.place_guess,
      positional_accuracy: obs.positional_accuracy,
      species_guess: obs.species_guess,
      taxon_id: obs.taxon && obs.taxon.id,
      uuid: obs.uuid,
    };
  }

  static mapObservationForMyObsDefaultMode( obs ) {
    return {
      uuid: obs.uuid,
      id: obs.id,
      observationPhotos: obs.observationPhotos.length > 0
        ? obs.observationPhotos
          .map( op => ObservationPhoto.mapObservationPhotoForMyObsDefaultMode( op ) )
        : [],
      observationSounds: obs.observationSounds.length > 0
        ? obs.observationSounds
          .map( os => ObservationSound.mapObservationSoundForMyObsDefaultMode( os ) )
        : [],
      quality_grade: obs.quality_grade,
      taxon: obs.taxon
        ? {
          id: obs?.taxon?.id,
          name: obs?.taxon?.name,
          preferred_common_name: obs?.taxon?.preferred_common_name,
          rank: obs?.taxon?.rank,
          rank_level: obs?.taxon?.rank_level,
          iconic_taxon_name: obs?.taxon?.iconic_taxon_name,
        }
        : null,
      comments_viewed: obs.comments_viewed,
      identifications_viewed: obs.identifications_viewed,
      missing_basics: typeof obs.missingBasics === "function"
        ? obs.missingBasics()
        : undefined,
      needs_sync: typeof obs.needsSync === "function"
        ? obs.needsSync()
        : obs.needs_sync,
    };
  }

  static mapObservationForMyObsAdvancedMode( obs ) {
    return {
      ...Observation.mapObservationForMyObsDefaultMode( obs ),
      comments: obs.comments.length > 0
        ? obs.comments
          .map( c => Comment.mapCommentForMyObsAdvancedMode( c ) )
        : [],
      geoprivacy: obs.geoprivacy,
      identifications: obs.identifications.length > 0
        ? obs.identifications
          .map( id => Identification.mapIdentificationForMyObsAdvancedMode( id ) )
        : [],
      latitude: obs.latitude,
      longitude: obs.longitude,
      obscured: obs.obscured,
      observed_on: obs.observed_on,
      observed_on_string: obs.observed_on_string,
      observed_time_zone: obs.observed_time_zone,
      place_guess: obs.place_guess,
      positional_accuracy: obs.positional_accuracy,
      privateLatitude: obs.privateLatitude,
      privateLongitude: obs.privateLongitude,
      taxon_geoprivacy: obs.taxon_geoprivacy,
      time_observed_at: obs.time_observed_at,
    };
  }

  static projectUri = obs => {
    const photo = obs?.observation_photos?.[0];
    if ( !photo ) { return null; }
    if ( !photo.photo ) { return null; }
    if ( !photo.photo.url ) { return null; }

    return { uri: obs.observation_photos[0].photo.url };
  };

  static filterUnsyncedObservations = realm => {
    const unsyncedFilter = "_synced_at == null || _synced_at <= _updated_at";
    const photosUnsyncedFilter = "ANY observationPhotos._synced_at == null";
    const soundsUnsyncedFilter = "ANY observationSounds._synced_at == null";

    const obs = realm.objects( "Observation" );
    // we sort unsynced observations here to make sure observations
    // with an older _created_at date get uploaded first
    const unsyncedObs = obs.filtered(
      `${unsyncedFilter} || ${photosUnsyncedFilter} || ${soundsUnsyncedFilter}`,
    ).sorted( "_created_at", true );
    return unsyncedObs;
  };

  static isUnsyncedObservation = ( realm, obs ) => {
    const obsList = Observation.filterUnsyncedObservations( realm );
    const unsyncedObs = obsList.filtered( `uuid == "${obs.uuid}"` );
    return unsyncedObs.length > 0;
  };

  static createObservationFromGalleryPhotos = async photos => {
    const photoUris = photos.map( photo => photo?.image?.uri );
    try {
      const newObservation = await readExifFromMultiplePhotos( photoUris );
      return Observation.new( newObservation );
    } catch ( createObservationFromGalleryError ) {
      logger.error(
        "Error reading EXIF from multiple gallery photos",
        createObservationFromGalleryError,
      );
      Alert.alert(
        "Creating Observation from Gallery Error",
        createObservationFromGalleryError.message,
      );
      return null;
    }
  };

  static createObservationWithPhotos = async photos => {
    const newLocalObs = await Observation.createObservationFromGalleryPhotos( photos );
    newLocalObs.observationPhotos = await ObservationPhoto
      .createObsPhotosWithPosition( photos, { position: 0 } );
    return newLocalObs;
  };

  static updateObsExifFromPhotos = async ( photoUris, currentObservation ) => {
    const updatedObs = currentObservation;

    const unifiedExif = await readExifFromMultiplePhotos( photoUris );

    if ( unifiedExif.latitude && !currentObservation.latitude ) {
      updatedObs.latitude = unifiedExif.latitude;
    }
    if ( unifiedExif.longitude && !currentObservation.longitude ) {
      updatedObs.longitude = unifiedExif.longitude;
    }
    if ( unifiedExif.observed_on_string && !currentObservation.observed_on_string ) {
      updatedObs.observed_on_string = unifiedExif.observed_on_string;
    }
    if ( unifiedExif.positional_accuracy && !currentObservation.positional_accuracy ) {
      updatedObs.positional_accuracy = unifiedExif.positional_accuracy;
    }

    return updatedObs;
  };

  static appendObsPhotos = ( obsPhotos, currentObservation ) => {
    const updatedObs = currentObservation;

    // need empty case for when a user creates an observation with no photos,
    // then tries to add photos to observation later
    const currentObservationPhotos = updatedObs?.observationPhotos || [];

    updatedObs.observationPhotos = [...currentObservationPhotos, ...obsPhotos];
    return updatedObs;
  };

  static appendObsSounds = ( obsSounds, currentObservation ) => {
    const updatedObs = currentObservation;

    // need empty case for when a user creates an observation with no sounds,
    // then tries to add sounds to observation later
    const currentObservationSounds = updatedObs?.observationSounds || [];

    updatedObs.observationSounds = [...currentObservationSounds, ...obsSounds];
    return updatedObs;
  };

  static deleteLocalObservation = ( realm, uuidToDelete ) => {
    const observation = realm?.objectForPrimaryKey( "Observation", uuidToDelete );
    if ( observation ) {
      safeRealmWrite( realm, ( ) => {
        realm?.delete( observation );
      }, `deleting local observation ${uuidToDelete} in deleteLocalObservation` );
    }
  };

  static markPendingDeletion( realm, uuidToDelete ) {
    const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete );
    if ( observation ) {
      safeRealmWrite( realm, ( ) => {
        observation._pending_deletion = true;
      } );
    }
  }

  static clearPendingDeletion( realm, uuidToDelete ) {
    const observation = realm.objectForPrimaryKey( "Observation", uuidToDelete );
    if ( observation ) {
      safeRealmWrite( realm, ( ) => {
        observation._pending_deletion = false;
      } );
    }
  }

  static schema = {
    name: "Observation",
    primaryKey: "uuid",
    properties: {
      _pending_deletion: "bool?",
      // datetime the observation was created on the device
      _created_at: "date?",
      // datetime the observation was requested to be deleted
      _deleted_at: "date?",
      // datetime the observation was last synced with the server
      _synced_at: "date?",
      // datetime the observation was updated on the device (i.e. edited locally)
      _updated_at: "date?",
      uuid: "string",
      application: "Application?",
      captive_flag: "bool?",
      comments: "Comment[]",
      // timestamp of when observation was created on the server; not editable
      created_at: { type: "string", mapTo: "createdAt", optional: true },
      description: "string?",
      geoprivacy: "string?",
      id: "int?",
      identifications: "Identification[]",
      latitude: "double?",
      license_code: { type: "string", mapTo: "licenseCode", optional: true },
      longitude: "double?",
      observationPhotos: "ObservationPhoto[]",
      observationSounds: "ObservationSound[]",
      // date and/or time submitted to the server when a new obs is uploaded
      observed_on_string: "string?",
      observed_on: "string?",
      observed_time_zone: "string?",
      obscured: "bool?",
      owners_identification_from_vision: "bool?",
      species_guess: "string?",
      place_guess: { type: "string", mapTo: "placeGuess", optional: true },
      positional_accuracy: "double?",
      prefers_community_taxon: "bool?",
      quality_grade: { type: "string", mapTo: "qualityGrade", optional: true },
      taxon: "Taxon?",
      taxon_geoprivacy: "string?",
      // datetime when the observer observed the organism; user-editable, but
      // only by changing observed_on_string
      time_observed_at: { type: "string", mapTo: "timeObservedAt", optional: true },
      user: "User?",
      updated_at: "date?",
      comments_viewed: "bool?",
      identifications_viewed: { type: "bool", mapTo: "identificationsViewed", optional: true },
      viewer_trusted_by_observer: {
        type: "bool",
        mapTo: "viewerTrustedByObserver",
        optional: true,
      },
      votes: "Vote[]",
      private_place_guess: { type: "string", mapTo: "privatePlaceGuess", optional: true },
      private_location: { type: "string", mapTo: "privateLocation", optional: true },
      privateLatitude: "double?",
      privateLongitude: "double?",
      needs_sync: { type: "bool", default: false, indexed: true },
    },
  };

  needsSync( ) {
    const obsPhotosNeedSync = this.observationPhotos
      .filter( obsPhoto => obsPhoto.needsSync( ) ).length > 0;
    const obsSoundsNeedSync = this.observationSounds
      .filter( obsSound => obsSound.needsSync( ) ).length > 0;
    return !this._synced_at
      || this._synced_at <= this._updated_at
      || obsPhotosNeedSync
      || obsSoundsNeedSync;
  }

  updateNeedsSync() {
    this.needsSync = this.needsSync();
  }

  wasSynced( ) {
    return this._synced_at !== null;
  }

  viewed() {
    return !!( this.comments_viewed && this.identifications_viewed );
  }

  unviewed() {
    return !this.viewed();
  }

  // Faves are the subset of votes for which vote_scope is null
  faves() {
    return this.votes.filter( vote => vote?.vote_scope === null );
  }

  missingBasics() {
    const missingDate = !Date.parse( this.observed_on_string ) && !this.time_observed_at;
    const missingCoords = typeof ( this.latitude ) !== "number"
      && typeof ( this.privateLatitude ) !== "number";
    return missingDate || missingCoords;
  }
}

export default Observation;
