package Convert::Pheno::BFF::ToOMOP;

use strict;
use warnings;
use autodie;
use feature qw(say);
use JSON::PP                      ();
use Scalar::Util                   qw(looks_like_number);
use Convert::Pheno::Utils::Default qw(get_defaults);
use Convert::Pheno::Mapping::Shared;
use Convert::Pheno::OMOP::Vocabulary qw(resolve_standard_concept);
use Exporter 'import';

our @EXPORT = qw(do_bff2omop);
my $DEFAULT                       = get_defaults();
my $MEASUREMENT_ID_COUNT          = 0;
my $DRUG_EXPOSURE_ID_COUNT        = 0;
my $OBSERVATION_ID_COUNT          = 0;
my $CONDITION_OCCURRENCE_COUNT    = 0;
my $PROCEDURE_OCCURRENCE_ID_COUNT = 0;

# OMOP standard concept fields are domain-specific. An exact label in another
# domain is not interchangeable, even when its text is identical.
my %INVERSE_CONFIG = (
    gender             => { domains => ['Gender'] },
    race               => { domains => ['Race'] },
    ethnicity          => { domains => ['Ethnicity'] },
    demographic        => { domains => [ 'Race', 'Ethnicity' ] },
    disease            => { domains => ['Condition'] },
    stage              => { domains => ['Condition Status'] },
    exposure           => { domains => ['Observation'] },
    phenotypicFeature  => { domains => ['Observation'] },
    procedure          => { domains => ['Procedure'] },
    measurement        => { domains => ['Measurement'] },
    measurementValue   => {
        domains       => ['Meas Value'],
        relationships => ['Maps to value'],
    },
    treatment => { domains => ['Drug'] },
    unit      => { domains => ['Unit'] },
    route     => { domains => ['Route'] },
);

###############
###############
#  BFF2OMOP   #
###############
###############

sub do_bff2omop {
    my ( $self, $bff ) = @_;

    # Premature return if no input
    return unless defined($bff);

    # Validate that input is in BFF format.
    die "Input format error: Are you sure your input is not already OMOP?\n"
      unless validate_format( $bff, 'bff' );

    # Convert ID string to ID integer
    my $person_id = looks_like_number( $bff->{id} )
      ? 0 + $bff->{id}
      : allocate_surrogate_integer( $self, 'omop_person_id', $bff->{id} );

    # Create a new OMOP structure.
    # This will be a hash with keys corresponding to OMOP table names.
    my $omop = {};

    # Convert individual components.
    # Pass the precomputed $person_id to each conversion sub.
    _map_person( $self, $bff, $omop, $person_id );
    _map_diseases( $self, $bff, $omop, $person_id );
    _map_exposures( $self, $bff, $omop, $person_id );
    _map_phenotypicFeatures( $self, $bff, $omop, $person_id );
    _map_interventionsOrProcedures( $self, $bff, $omop, $person_id );
    _map_measurements( $self, $bff, $omop, $person_id );
    _map_treatments( $self, $bff, $omop, $person_id );

    # (Optionally, additional tables such as VISIT_OCCURRENCE or OBSERVATION_PERIOD
    # could be derived from extra info in $bff.)
    #print Dumper $omop;
    return $omop;
}

###############################################################################
# Private conversion subs (each inverts part of the OMOP2BFF mapping)
###############################################################################

# Convert BFF subject and info into a PERSON record.
sub _map_person {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;

    my $person;

    # Miscellanea id
    $person->{person_id}           = $person_id;
    $person->{person_source_value} = $bff->{id};

    # Phenopacket-derived BFF stores subject metadata under info.phenopacket;
    # older BFF producers may still place dateOfBirth directly under info.
    my $info = ref( $bff->{info} ) eq 'HASH' ? $bff->{info} : {};
    my $phenopacket_info =
      ref( $info->{phenopacket} ) eq 'HASH' ? $info->{phenopacket} : {};
    my $date_of_birth =
      $phenopacket_info->{dateOfBirth} // $info->{dateOfBirth};

    # Map dateOfBirth (if available) to birth_datetime.
    $person->{birth_datetime} = $date_of_birth // $DEFAULT->{timestamp};

    # Map dateOfBirth (if available) to birth_datetime.
    if ( defined($date_of_birth) ) {
        for (qw/year month day/) {
            $person->{ $_ . '_of_birth' } =
              get_date_component( $date_of_birth, $_ );
        }
    }
    else {
        $person->{year_of_birth} = $DEFAULT->{year};
    }

    # Convert sex: now done via our new generic inverse_map.
    (
        $person->{gender_concept_id},
        $person->{gender_source_value},
        $person->{gender_source_concept_id}
      ) =
      inverse_map( 'gender', $bff->{sex}, 'label', $self );

    ( $person->{race_concept_id}, $person->{race_source_value},
        $person->{race_source_concept_id} ) =
      ( $DEFAULT->{concept_id}, '', $DEFAULT->{concept_id} );
    ( $person->{ethnicity_concept_id}, $person->{ethnicity_source_value},
        $person->{ethnicity_source_concept_id} ) =
      ( $DEFAULT->{concept_id}, '', $DEFAULT->{concept_id} );

    if ( exists $bff->{ethnicity} ) {
        my ( $concept_id, $source_value, $source_concept_id, $resolution ) =
          inverse_map( 'demographic', $bff->{ethnicity}, 'label', $self );
        my $target = $resolution->{target_domain} // 'Race';
        my $prefix = $target eq 'Ethnicity' ? 'ethnicity' : 'race';
        $person->{ $prefix . '_concept_id' }        = $concept_id;
        $person->{ $prefix . '_source_value' }      = $source_value;
        $person->{ $prefix . '_source_concept_id' } = $source_concept_id;
    }

    # Some synthetic OMOP datasets place country of birth in
    # ethnicity_source_value. Retain that established fallback only when BFF
    # did not provide a term that resolves to the OMOP Ethnicity domain.
    if ( exists $bff->{geographicOrigin}
        && !$person->{ethnicity_concept_id} )
    {
        (
            $person->{ethnicity_concept_id},
            $person->{ethnicity_source_value},
            $person->{ethnicity_source_concept_id}
          ) = inverse_map( 'ethnicity', $bff->{geographicOrigin}, 'label', $self );
    }

    # Save the PERSON record one person per individual)
    $omop_ref->{PERSON} = $person;

}

# Convert BFF diseases into OMOP CONDITION_OCCURRENCE rows.
sub _map_diseases {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;

    my @conditions;

    for my $disease ( @{ $bff->{diseases} // [] } ) {
        my $cond;

        $cond->{condition_occurrence_id} = ++$CONDITION_OCCURRENCE_COUNT;

        # We now call our generic inverse_map:
        (
            $cond->{condition_concept_id},
            $cond->{condition_source_value},
            $cond->{condition_source_concept_id}
          ) =
          inverse_map( 'disease', $disease->{diseaseCode}, 'label', $self );

        # Convert onset (e.g., an ISO8601 duration) to a date
        if ( $disease->{ageOfOnset}{age}{iso8601duration} ) {
            $cond->{condition_start_date} = get_date_at_age(
                $disease->{ageOfOnset}{age}{iso8601duration},
                $omop_ref->{PERSON}{year_of_birth}
            );
        }
        else {
            $cond->{condition_start_date} = $DEFAULT->{date};
        }

        # TEMPORARY SOLUTION: Setting defaults
        # mrueda: Apr-2025
        $cond->{condition_type_concept_id} = $DEFAULT->{concept_id};

        _attach_common( $self, $cond, $disease, $person_id );

        # Optionally map stage to condition_status_concept_id.
        #if ( exists $disease->{stage} ) {
        #}

        push @conditions, $cond;
    }
    $omop_ref->{CONDITION_OCCURRENCE} = \@conditions if @conditions;

}

# Convert BFF exposures into OMOP OBSERVATION rows.
sub _map_exposures {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;

    my @observations;

    for my $exposure ( @{ $bff->{exposures} // [] } ) {
        my $obs;

        $obs->{observation_id} = ++$OBSERVATION_ID_COUNT;

        # e.g., $exposure->{exposureCode} used in a generic mapping:
        (
            $obs->{observation_concept_id},
            $obs->{observation_source_value},
            $obs->{observation_source_concept_id}
          ) =
          inverse_map( 'exposure', $exposure->{exposureCode}, 'label', $self );

        # Date
        $obs->{observation_date} = $exposure->{date};

        # BFF only accepts numeric
        $obs->{value_as_number} =
          defined $exposure->{value} ? $exposure->{value} : -1;

        ( $obs->{unit_concept_id}, $obs->{unit_source_value} ) =
          inverse_map( 'unit', $exposure->{unit}, 'label', $self );

        # TEMPORARY: BFF only accepts numeric
        $obs->{value_as_concept_id} = '';
        $obs->{value_as_string}     = '';

        # TEMPORARY SOLUTION: Setting defaults
        # mrueda: Apr-2025
        $obs->{observation_type_concept_id} = $DEFAULT->{concept_id};

        _attach_common( $self, $obs, $exposure, $person_id );

        push @observations, $obs;
    }
    $omop_ref->{OBSERVATION} = \@observations if @observations;
}

# Convert BFF phenotypicFeatures into additional OMOP OBSERVATION rows.
sub _map_phenotypicFeatures {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;

    my @observations;

    for my $feature ( @{ $bff->{phenotypicFeatures} // [] } ) {

        my $obs;

        next
          if ( $feature->{excluded} && $feature->{excluded} == JSON::PP::true() );

        $obs->{observation_id} = ++$OBSERVATION_ID_COUNT;

        # e.g., $feature->{featureType} used in a generic mapping:
        (
            $obs->{observation_concept_id},
            $obs->{observation_source_value},
            $obs->{observation_source_concept_id}
          ) =
          inverse_map( 'phenotypicFeature', $feature->{featureType},
            'label', $self );

        # Date
        if ( $feature->{onset}{iso8601duration} ) {
            $obs->{observation_date} = get_date_at_age(
                $feature->{onset}{iso8601duration},
                $omop_ref->{PERSON}{year_of_birth}
            );
        }
        else {
            $obs->{observation_date} = $DEFAULT->{date};
        }

        # BFF only accepts numeric
        $obs->{value_as_number} =
          defined $feature->{value} ? $feature->{value} : -1;

        ( $obs->{unit_concept_id}, $obs->{unit_source_value} ) =
          inverse_map( 'unit', $feature->{unit}, 'label', $self );

        # TEMPORARY: BFF only accepts numeric
        $obs->{value_as_concept_id} = '';
        $obs->{value_as_string}     = '';

        # TEMPORARY SOLUTION: Setting defaults
        # mrueda: Apr-2025
        $obs->{observation_type_concept_id} = $DEFAULT->{concept_id};

        _attach_common( $self, $obs, $feature, $person_id );

        push @observations, $obs;
    }

    # Append these observations to any existing ones.
    if (@observations) {
        if ( exists $omop_ref->{OBSERVATION} ) {
            push @{ $omop_ref->{OBSERVATION} }, @observations;
        }
        else {
            $omop_ref->{OBSERVATION} = \@observations;
        }
    }
}

# Convert BFF interventionsOrProcedures into OMOP PROCEDURE_OCCURRENCE rows.
sub _map_interventionsOrProcedures {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;
    my @procedures;

    for my $proc ( @{ $bff->{interventionsOrProcedures} // [] } ) {

        my $procedure;

        $procedure->{procedure_occurrence_id} =
          ++$PROCEDURE_OCCURRENCE_ID_COUNT;

        (
            $procedure->{procedure_concept_id},
            $procedure->{procedure_source_value},
            $procedure->{procedure_source_concept_id}
          )
          = inverse_map( 'procedure', $proc->{procedureCode}, 'label', $self );

        $procedure->{procedure_date} =
          defined $proc->{dateOfProcedure}
          ? $proc->{dateOfProcedure}
          : $DEFAULT->{date};

        $procedure->{procedure_datetime} =
          defined $proc->{dateOfProcedure}
          ? map_iso8601_date2timestamp( $proc->{dateOfProcedure} )
          : $DEFAULT->{timestamp};

        # TEMPORARY SOLUTION: Setting defaults
        # mrueda: Apr-2025
        $procedure->{procedure_type_concept_id} = $DEFAULT->{concept_id};

        _attach_common( $self, $procedure, $proc, $person_id );

        push @procedures, $procedure;
    }
    $omop_ref->{PROCEDURE_OCCURRENCE} = \@procedures if @procedures;
}

# Convert BFF measures into OMOP MEASUREMENT rows.
sub _map_measurements {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;
    my @measurements;

    for my $measure ( @{ $bff->{measures} // [] } ) {

        my $m;
        $m->{measurement_id} = ++$MEASUREMENT_ID_COUNT;

        (
            $m->{measurement_concept_id},
            $m->{measurement_source_value},
            $m->{measurement_source_concept_id}
          ) =
          inverse_map( 'measurement', $measure->{assayCode}, 'label', $self );
        $m->{measurement_date} = $measure->{date};

        # Determine measurement value. Beacon permits either a quantity or an
        # ontology term; the latter maps to OMOP value_as_concept_id.
        if ( exists $measure->{measurementValue} ) {
            my $value = $measure->{measurementValue};

            if ( ref($value) eq 'HASH' ) {
                my $quantity =
                    exists $value->{quantity} ? $value->{quantity}
                  : exists $value->{value} && exists $value->{unit} ? $value
                  : undef;

                if ( ref($quantity) eq 'HASH' ) {
                    $m->{value_as_number} = $quantity->{value} // -1;
                    if ( ref( $quantity->{referenceRange} ) eq 'HASH' ) {
                        $m->{range_low}  = $quantity->{referenceRange}{low};
                        $m->{range_high} = $quantity->{referenceRange}{high};
                    }
                    if ( ref( $quantity->{unit} ) eq 'HASH' ) {
                        (
                            $m->{unit_concept_id},
                            $m->{unit_source_value},
                            $m->{unit_source_concept_id}
                          ) =
                          inverse_map( 'unit', $quantity->{unit}, 'label', $self );
                    }
                }
                elsif ( exists $value->{id} ) {
                    ( $m->{value_as_concept_id}, $m->{value_source_value} ) =
                      inverse_map( 'measurementValue', $value, 'label', $self );
                }
                else {
                    $m->{value_as_number} = -1;
                }
            }
            else {
                $m->{value_as_number} = $value;
            }
        }
        else {
            $m->{value_as_number} = -1;
        }

        $m->{value_source_value} = $m->{value_as_number}
          unless exists $m->{value_source_value};

        # A BFF measurement procedure describes how the assay was performed;
        # it is not OMOP provenance and must not populate a Type Concept field.
        $m->{measurement_type_concept_id} = $DEFAULT->{concept_id};

        _attach_common( $self, $m, $measure, $person_id );

        push @measurements, $m;
    }
    $omop_ref->{MEASUREMENT} = \@measurements if @measurements;
}

# Convert BFF treatments into OMOP DRUG_EXPOSURE rows.
sub _map_treatments {
    my ( $self, $bff, $omop_ref, $person_id ) = @_;
    my @treatments;

    for my $treatment ( @{ $bff->{treatments} // [] } ) {
        my $drug;

        $drug->{drug_exposure_id} = ++$DRUG_EXPOSURE_ID_COUNT;

        # TEMPORARY
        $drug->{drug_type_concept_id} = 0;

        (
            $drug->{drug_concept_id},
            $drug->{drug_source_value},
            $drug->{drug_source_concept_id}
          ) =
          inverse_map( 'treatment', $treatment->{treatmentCode},
            'label', $self );

        if ( exists $treatment->{doseIntervals}
            and @{ $treatment->{doseIntervals} } )
        {
            my $dose = $treatment->{doseIntervals}[0];
            $drug->{quantity}               = $dose->{quantity}{value} // -1;
            $drug->{dose_unit_source_value} = $dose->{quantity}{unit}{label};

        }
        else {
            $drug->{quantity} = -1;
        }

        ( $drug->{route_concept_id}, $drug->{route_source_value} ) =
          inverse_map( 'route', $treatment->{routeOfAdministration},
            'label', $self );

        my ( $interval_start, $interval_end );
        if ( exists $treatment->{doseIntervals}
            and @{ $treatment->{doseIntervals} } )
        {
            my $interval = $treatment->{doseIntervals}[0]{interval} // {};
            $interval_start = $interval->{start};
            $interval_end   = $interval->{end};
        }

        # Prefer explicit dose interval dates. If only one bound is present,
        # reuse it for both OMOP-required date fields.
        if ( $interval_start || $interval_end ) {
            my $resolved_start = $interval_start // $interval_end;
            my $resolved_end   = $interval_end   // $interval_start;

            $drug->{drug_exposure_start_date} =
              map_iso8601_timestamp2date($resolved_start);
            $drug->{drug_exposure_end_date} =
              map_iso8601_timestamp2date($resolved_end);
        }
        elsif ( $treatment->{ageOfOnset}{age}{iso8601duration} ) {
            $drug->{drug_exposure_start_date} =
              get_date_at_age( $treatment->{ageOfOnset}{age}{iso8601duration},
                $omop_ref->{PERSON}{year_of_birth} );

            # TEMPORARY
            $drug->{drug_exposure_end_date} = $drug->{drug_exposure_start_date};
        }
        else {
            $drug->{drug_exposure_start_date} = $DEFAULT->{date};

            # TEMPORARY
            $drug->{drug_exposure_end_date} = $drug->{drug_exposure_start_date};
        }

        $drug->{days_supply} =
          $treatment->{cumulativeDose}{unit}{label}
          ? convert_label_to_days(
            $treatment->{cumulativeDose}{unit}{label},
            $treatment->{cumulativeDose}{value}
            )
          : '';

        _attach_common( $self, $drug, $treatment, $person_id );

        push @treatments, $drug;
    }
    $omop_ref->{DRUG_EXPOSURE} = \@treatments if @treatments;
}

sub inverse_map {
    my ( $mapping_type, $hashref, $key, $self ) = @_;

    my $config = $INVERSE_CONFIG{$mapping_type};
    unless ($config) {
        warn "Unknown mapping type <$mapping_type>; returning (0,'')\n";
        return ( 0, '', 0 );
    }

    my $term = ref($hashref) eq 'HASH'
      ? $hashref
      : { $key => defined $hashref ? $hashref : q{} };
    my $result = resolve_standard_concept(
        {
            self         => $self,
            term         => $term,
            mapping_type => $mapping_type,
            %{$config},
        }
    );

    return (
        $result->{concept_id},
        $result->{source_value},
        $result->{source_concept_id},
        $result,
    );
}

sub _attach_common {

    # Individuals default schema does not provice anything related to visit
    # Taking information if Convert-Pheno set it

    my ( $self, $row, $ent, $pid ) = @_;
    my $v = $ent->{_visit} // undef;
    my $visit_occurrence_id = $v->{occurrence_id} // '';
    if ( defined $visit_occurrence_id
        && $visit_occurrence_id ne ''
        && !looks_like_number($visit_occurrence_id) )
    {
        my $visit_key = $v->{composite} // $visit_occurrence_id;
        $visit_occurrence_id =
          allocate_surrogate_integer( $self, 'omop_visit_occurrence_id', $visit_key );
    }
    $row->{visit_occurrence_id} = $visit_occurrence_id;
    $row->{visit_detail_id}     = $v->{detail_id} // '';
    $row->{person_id}           = $pid;
}

1;
