package Convert::Pheno;

use strict;
use warnings;
use autodie;
use feature               qw(say);
use File::Spec::Functions qw(catdir catfile);
use Data::Dumper;
use File::Basename;
use File::ShareDir::ProjectDistDir;
use List::Util qw(any uniq);
use Moo;
use Types::Standard                qw(Str Int Num Enum ArrayRef Undef);
use File::ShareDir::ProjectDistDir qw(dist_dir);
#use Devel::Size     qw(size total_size);
use Convert::Pheno::IO::CSVHandler;
use Convert::Pheno::IO::FileIO;
use Convert::Pheno::Context;
use Convert::Pheno::Pipeline qw(run_conversion_pipeline);
use Convert::Pheno::Runner qw(run_operation);
use Convert::Pheno::Source qw(source_adapter);
use Convert::Pheno::Operations qw(conversion_spec);
use Convert::Pheno::Emit::OMOP qw(
  dispatcher_open_stream_out
  transform_item
  finalize_stream_out
  omop_stream_targets_open
  omop_stream_targets_write
  omop_stream_targets_finalize
  omop_streams_multiple_entities
);
use Convert::Pheno::OMOP::ParticipantStream qw(
  omop_require_core_tables
  omop_init_caches_and_metadata
  omop_prepare_data_shape
);
use Convert::Pheno::OMOP::Definitions;
use Convert::Pheno::DB::SQLite;
use Convert::Pheno::Mapping::Shared;
use Convert::Pheno::CSV;
use Convert::Pheno::JSONLD qw(do_bff2jsonld do_pxf2jsonld);
use Convert::Pheno::OMOP::ToBFF qw(do_omop2bff);
use Convert::Pheno::PXF::ToBFF;
use Convert::Pheno::OpenEHR::ToBFF;
use Convert::Pheno::BFF::ToPXF;
use Convert::Pheno::BFF::ToOMOP;
use Convert::Pheno::CBioPortal::ToBFF;
use Convert::Pheno::CDISC::ODM;
use Convert::Pheno::CDISC::SDTM::ToBFF;
use Convert::Pheno::FHIR::ToBFF;
use Convert::Pheno::REDCap;

use Exporter 'import';
our @EXPORT =
  qw($VERSION io_yaml_or_json omop2bff_stream_processing share_dir);    # Symbols imported by default

#our @EXPORT_OK = qw(foo bar);       # Symbols imported by request

use constant DEVEL_MODE => 0;

# Global variables:
our $VERSION   = '0.34';
our $share_dir = dist_dir('Convert-Pheno');

# SQLite database
my @all_sqlites       = qw(ncit icd10 ohdsi cdisc omim hpo);
my @non_ohdsi_sqlites = qw(ncit icd10 cdisc omim hpo);

# Define a subroutine that computes the default username.
my $default_username = sub {
    return $ENV{'LOGNAME'} || $ENV{'USER'} || $ENV{'USERNAME'} || 'dummy-user';
};

############################################
# Start declaring attributes for the class #
############################################

# Complex defaults here
has search => (
    is      => 'ro',
    default => sub { 'exact' },
    coerce  => sub { $_[0] // 'exact' },
    isa     => Enum [qw(exact mixed fuzzy)]
);

has text_similarity_method => (
    is      => 'ro',
    default => sub { 'cosine' },
    coerce  => sub { $_[0] // 'cosine' },
    isa     => Enum [qw(cosine dice)]
);

has min_text_similarity_score => (
    is      => 'ro',
    default => sub { 0.8 },
    coerce  => sub { $_[0] // 0.8 },
    isa     => sub {
        die "Only values between 0 .. 1 supported!"
          unless ( $_[0] >= 0.0 && $_[0] <= 1.0 );
    }
);
has levenshtein_weight => (
    is      => 'ro',
    default => sub { 0.1 },
    coerce  => sub { $_[0] // 0.1 },
    isa     => sub {
        die "Only values between 0 .. 1 supported!"
          unless ( $_[0] >= 0.0 && $_[0] <= 1.0 );
    }
);

has username => (
    is      => 'ro',
    isa     => Str,
    default => $default_username,    # Use the subroutine for the default.
    coerce  => sub {
        $_[0] // $default_username->();
    },
);

has id => (
    is      => 'ro',
    isa     => Str,
    default => sub { time . substr( "00000$$", -5 ) },
    coerce  => sub { $_[0] // time . substr( "00000$$", -5 ) },
);

has max_lines_sql => (
    default => 500,                    # Limit to speed up runtime
    is      => 'ro',
    coerce  => sub { $_[0] // 500 },
    isa     => Int
);

has 'omop_tables' => (
    default => sub { [@omop_supported_tables] },
    coerce  => sub {
        my $tables = shift;

        $tables =
          @$tables
          ? [ uniq( map { uc($_) } ( 'CONCEPT', 'PERSON', @$tables ) ) ]
          : \@omop_supported_tables;

        return $tables;
    },
    is  => 'rw',
    isa => ArrayRef
);

has exposures_file => (
    default =>
      catfile( $share_dir, 'db', 'concepts_candidates_2_exposure.csv' ),
    coerce => sub {
        $_[0]
          // catfile( $share_dir, 'db', 'concepts_candidates_2_exposure.csv' );
    },
    is  => 'ro',
    isa => Str
);

# Miscellanea atributes here
has [qw /test self_validate_schema path_to_ohdsi_db/] =>
  ( default => undef, is => 'ro' );

has [qw /stream ohdsi_db/] => ( default => 0, is => 'ro' );
has source_info => ( default => 1, is => 'ro' );

has default_vital_status => (
    is     => 'ro',
    coerce => sub { $_[0] // 'ALIVE' },
    isa    => Enum [qw(ALIVE DECEASED UNKNOWN_STATUS)]
);

has [qw /in_files/] => ( default => sub { [] }, is => 'ro' );

has [
    qw /out_file out_dir in_textfile in_file sep sql2csv redcap_dictionary mapping_file schema_file define_xml debug log verbose term_audit_file/
] => ( is => 'ro' );

has [qw /data method/] => ( is => 'rw' );
has entities => ( is => 'ro', default => sub { ['individuals'] } );
has derived_entity_overrides => ( is => 'ro', default => sub { {} } );
has output_name_overrides => ( is => 'ro', default => sub { {} } );

##########################################
# End declaring attributes for the class #
##########################################

sub BUILD {
    my $self = shift;
    $self->{databases} =
      $self->{ohdsi_db} ? \@all_sqlites : \@non_ohdsi_sqlites;
}

#############
#############
#  BFF2PXF  #
#############
#############

sub bff2pxf {
    my $self = shift;
    return _run_primary_view($self);
}

#############
#############
#  BFF2CSV  #
#############
#############

sub bff2csv {
    my $self = shift;
    return _run_primary_view($self);
}

#############
#############
# BFF2JSONF #
#############
#############

sub bff2jsonf {
    my $self = shift;
    return _run_primary_view($self);
}

##############
##############
# BFF2JSONLD #
##############
##############

sub bff2jsonld {
    my $self = shift;
    return _run_primary_view($self);
}

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

sub bff2omop {
    my $self = shift;
    return merge_omop_tables( _run_primary_view($self) );
}

################
################
#  REDCAP2BFF  #
################
################

sub redcap2bff {
    my $self = shift;
    _prepare_redcap2bff_input($self);
    return _run_primary_view($self);
}

################
################
#  REDCAP2PXF  #
################
################

sub redcap2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

#################
#################
#  REDCAP2OMOP  #
#################
#################

sub redcap2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

###################
###################
# CBIOPORTAL2BFF   #
###################
###################

sub cbioportal2bff {
    my $self = shift;
    _prepare_cbioportal2bff_input($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'cbioportal',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

###################
###################
# CBIOPORTAL2PXF   #
###################
###################

sub cbioportal2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

###################
###################
# CBIOPORTAL2OMOP  #
###################
###################

sub cbioportal2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

##########################################################
# OMOP helpers - contain state mutation & pipeline       #
##########################################################

sub _with_temp_self_field {
    my ( $self, $field, $value, $code ) = @_;
    return _with_temp_self_fields( $self, { $field => $value }, $code );
}

sub _omop_collect_input {
    my ($self) = @_;
    return source_adapter( $self, 'omop' )->load;
}

sub _omop_require_core_tables {
    return omop_require_core_tables(@_);
}

sub _omop_init_caches_and_metadata {
    return omop_init_caches_and_metadata(@_);
}

sub _omop_prepare_data_shape {
    return omop_prepare_data_shape(@_);
}

##############
##############
#  OMOP2BFF  #
##############
##############

sub omop2bff {
    my $self = shift;
    _prepare_omop2bff_input($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'omop',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );

    if ( $self->{stream} ) {
        return _with_prepared_data_cleanup(
            $self,
            sub {
                return omop_stream_dispatcher(
                    {
                        self      => $self,
                        filepath  => $self->{filepath_sql},
                        filepaths => $self->{filepaths_csv},
                    }
                );
            }
        );
    }

    return _run_primary_view($self);
}

##############
##############
#  OMOP2PXF  #
##############
##############

sub omop2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

###############
###############
# CDISCODM2BFF #
###############
###############

sub cdiscodm2bff {
    my $self = shift;
    _prepare_cdiscodm2bff_input($self);
    return _run_primary_view($self);
}

###############
###############
# CDISCODM2PXF #
###############
###############

sub cdiscodm2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

################
################
# CDISCODM2OMOP #
################
################

sub cdiscodm2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

####################
####################
# DATASETJSON2BFF   #
####################
####################

sub datasetjson2bff {
    my $self = shift;
    _prepare_datasetjson2bff_input($self);
    $self->{convertPheno} ||= get_info($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'dataset-json',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

####################
####################
# DATASETJSON2PXF   #
####################
####################

sub datasetjson2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

####################
####################
# DATASETJSON2OMOP  #
####################
####################

sub datasetjson2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

###################
###################
# DATASETXML2BFF   #
###################
###################

sub datasetxml2bff {
    my $self = shift;
    _prepare_datasetxml2bff_input($self);
    $self->{convertPheno} ||= get_info($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'dataset-xml',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

###################
###################
# DATASETXML2PXF   #
###################
###################

sub datasetxml2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

###################
###################
# DATASETXML2OMOP  #
###################
###################

sub datasetxml2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

##############
##############
#  FHIR2BFF  #
##############
##############

sub fhir2bff {
    my $self = shift;
    _prepare_fhir2bff_input($self);
    $self->{convertPheno} ||= get_info($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'fhir',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

##############
##############
#  FHIR2PXF  #
##############
##############

sub fhir2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

##############
##############
# FHIR2OMOP  #
##############
##############

sub fhir2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

#############
#############
#  PXF2BFF  #
#############
#############

sub pxf2bff {
    my $self = shift;
    $self->{convertPheno} = get_info($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'pxf',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

##############
##############
#  PXF2OMOP  #
##############
##############

sub pxf2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

#################
#################
# OPENEHR2BFF   #
#################
#################

sub openehr2bff {
    my $self = shift;
    _prepare_openehr2bff_input($self);
    $self->{convertPheno} ||= get_info($self);
    $self->{conversion_context} = Convert::Pheno::Context->from_self(
        $self,
        {
            source_format => 'openehr',
            target_format => 'beacon',
            entities      => $self->{entities} || ['individuals'],
        }
    );
    return _run_primary_view($self);
}

#################
#################
# OPENEHR2PXF   #
#################
#################

sub openehr2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

#############
#############
#  CSV2BFF  #
#############
#############

sub csv2bff {
    my $self = shift;
    _prepare_csv2bff_input($self);
    return _run_primary_view($self);
}

#############
#############
#  CSV2PXF  #
#############
#############

sub csv2pxf {
    my $self = shift;
    return run_conversion_pipeline($self);
}

##############
##############
#  CSV2OMOP  #
##############
##############

sub csv2omop {
    my $self = shift;
    return run_conversion_pipeline($self);
}

#############
#############
#  PXF2CSV  #
#############
#############

sub pxf2csv {
    my $self = shift;
    return _run_primary_view($self);
}

#############
#############
# PXFJSONF  #
#############
#############

sub pxf2jsonf {
    my $self = shift;
    return _run_primary_view($self);
}

##############
##############
# PXF2JSONLD #
##############
##############

sub pxf2jsonld {
    my $self = shift;
    return _run_primary_view($self);
}

#################
#################
#  HELPER SUBS  #
#################
#################

sub _dispatcher_input_data {
    my ($self) = @_;
    return $self->{data} if exists $self->{data};
    return $self->{data}
      unless $self->{in_textfile}
      && $self->{method} !~ m/^(redcap2|omop2|cdiscodm2|csv)/;

    my $spec = conversion_spec( $self->{method} )
      or die "Unsupported conversion <$self->{method}>\n";
    return source_adapter( $self, $spec->{source} )->load->data;
}

sub _dispatcher_open_stream_out {
    return dispatcher_open_stream_out(@_);
}

sub _run_primary_view {
    my ($self) = @_;
    return _run_view( $self, 'primary' );
}

sub _run_bundle_view {
    my ($self) = @_;
    _prepare_bundle_input($self);
    return _run_view( $self, 'bundle' );
}

sub _run_view {
    my ( $self, $view ) = @_;
    my $input = _dispatcher_input_data($self);

    return _with_prepared_data_cleanup(
        $self,
        sub {
            return run_operation(
                $self,
                $input,
                view => $view,
            );
        }
    );
}

sub _with_prepared_data_cleanup {
    my ( $self, $code ) = @_;
    my $owns_prepared_data = delete $self->{_owns_prepared_data};

    my ( $ok, $error, $result );
    $ok = eval {
        $result = $code->();
        1;
    };
    $error = $@ unless $ok;

    # Module callers own their references. Only release buffers loaded or
    # derived internally; clearing a caller's array corrupts reusable input.
    delete $self->{data} if $owns_prepared_data;

    die $error unless $ok;
    return $result;
}

sub _prepare_bundle_input {
    my ($self) = @_;

    return _prepare_redcap2bff_input($self) if $self->{method} eq 'redcap2bff';
    return _prepare_cbioportal2bff_input($self)
      if $self->{method} eq 'cbioportal2bff';
    return _prepare_cdiscodm2bff_input($self)
      if $self->{method} eq 'cdiscodm2bff';
    return _prepare_datasetjson2bff_input($self)
      if $self->{method} eq 'datasetjson2bff';
    return _prepare_datasetxml2bff_input($self)
      if $self->{method} eq 'datasetxml2bff';
    return _prepare_fhir2bff_input($self)
      if $self->{method} eq 'fhir2bff';
    return _prepare_csv2bff_input($self)    if $self->{method} eq 'csv2bff';
    return _prepare_openehr2bff_input($self)
      if $self->{method} eq 'openehr2bff' || $self->{method} eq 'openehr2pxf';
    if ( $self->{method} eq 'omop2bff' ) {
        delete $self->{mapping_file_derived_entity_overrides};
        return _prepare_omop2bff_input($self);
    }

    # PXF bundle mode bypasses the public method, so initialize conversion
    # provenance here as well for synthesized dataset/cohort metadata.
    delete $self->{mapping_file_derived_entity_overrides};
    $self->{convertPheno} ||= get_info($self)
      if $self->{method} eq 'pxf2bff'
      || $self->{method} eq 'openehr2bff'
      || $self->{method} eq 'openehr2pxf';

    return 1;
}

sub _prepare_redcap2bff_input {
    my ($self) = @_;
    return 1 if exists $self->{data} && exists $self->{data_mapping_file};
    return _prepare_tabular_input( $self, 'redcap' );
}

sub _prepare_cbioportal2bff_input {
    my ($self) = @_;
    return 1 if $self->{cbioportal_input_prepared} && exists $self->{data};

    # Keep a module caller's in-memory package reusable while releasing the
    # normalized patient-scoped records after each conversion.
    $self->{_cbioportal_source_data} = $self->{data}
      if exists $self->{data} && !exists $self->{_cbioportal_source_data};
    $self->{data} = $self->{_cbioportal_source_data}
      if !exists $self->{data} && exists $self->{_cbioportal_source_data};

    my $source = source_adapter( $self, 'cbioportal' )->load;
    $self->{data} = $source->data;
    $self->{_owns_prepared_data} = 1 if $source->owned;
    $self->{cbioportal_study} = $source->artifact('study');

    my $compiled_mapping = $source->artifact('entity_mapping');
    if ( defined $compiled_mapping ) {
        $self->{data_mapping_file} = $compiled_mapping;
    }
    else {
        delete $self->{data_mapping_file};
    }

    my $loaded_mapping = $source->artifact('mapping');
    $self->{mapping_file_derived_entity_overrides} =
      _mapping_file_derived_entity_overrides($loaded_mapping);
    $self->{metaData}     = get_metaData($self);
    $self->{convertPheno} = get_info($self);
    $self->{cbioportal_input_prepared} = 1;
    return 1;
}

sub _prepare_cdiscodm2bff_input {
    my ($self) = @_;
    return 1 if exists $self->{data} && exists $self->{data_mapping_file};
    return _prepare_tabular_input( $self, 'cdisc-odm' );
}

sub _prepare_datasetjson2bff_input {
    my ($self) = @_;
    return _prepare_sdtm_dataset_input( $self, 'dataset-json', 'dataset_json' );
}

sub _prepare_datasetxml2bff_input {
    my ($self) = @_;
    return _prepare_sdtm_dataset_input( $self, 'dataset-xml', 'dataset_xml' );
}

sub _prepare_sdtm_dataset_input {
    my ( $self, $source_format, $state_prefix ) = @_;
    my $prepared_key    = $state_prefix . '_input_prepared';
    my $source_data_key = '_' . $state_prefix . '_source_data';

    return 1 if $self->{$prepared_key} && exists $self->{data};

    # Keep caller-owned source documents separate from the normalized
    # participant buffer so a module converter can be reused safely.
    $self->{$source_data_key} = $self->{data}
      if exists $self->{data} && !exists $self->{$source_data_key};
    $self->{data} = $self->{$source_data_key}
      if !exists $self->{data} && exists $self->{$source_data_key};

    my $source = source_adapter( $self, $source_format )->load;
    $self->{data} = $source->data;
    $self->{_owns_prepared_data} = 1 if $source->owned;
    $self->{ $state_prefix . '_metadata' } =
      $source->artifact('dataset_metadata');
    $self->{ $state_prefix . '_subject_independent_domains' } =
      $source->artifact('subject_independent_domains');
    $self->{source_derived_entity_overrides} =
      $source->artifact('derived_entity_overrides') || {};
    $self->{sdtm_terminology_mapping} =
      $source->artifact('terminology_mapping') || {};
    $self->{sdtm_source_terms} =
      $source->artifact('source_terminology') || {};
    $self->{terminology_lookup_required} =
      $source->artifact('terminology_requires_sqlite') ? 1 : 0;
    $self->{convertPheno} ||= get_info($self);
    $self->{$prepared_key} = 1;

    return 1;
}

sub _prepare_fhir2bff_input {
    my ($self) = @_;
    return 1 if $self->{fhir_input_prepared} && exists $self->{data};

    # Keep caller-owned Bundles reusable while releasing the normalized,
    # patient-scoped buffer after conversion.
    $self->{_fhir_source_data} = $self->{data}
      if exists $self->{data} && !exists $self->{_fhir_source_data};
    $self->{data} = $self->{_fhir_source_data}
      if !exists $self->{data} && exists $self->{_fhir_source_data};

    my $source = source_adapter( $self, 'fhir' )->load;
    my $patients = $source->data;
    $self->{data} = $patients;
    $self->{_owns_prepared_data} = 1 if $source->owned;
    $self->{fhir_bundle_metadata} = $source->artifact('bundle_metadata');
    $self->{fhir_unassigned_resources} =
      $source->artifact('unassigned_resources');
    $self->{source_derived_entity_overrides} =
      $source->artifact('derived_entity_overrides') || {};
    $self->{convertPheno} ||= get_info($self);
    $self->{fhir_input_prepared} = 1;

    return 1;
}

sub _prepare_csv2bff_input {
    my ($self) = @_;
    return 1 if exists $self->{data} && exists $self->{data_mapping_file};
    return _prepare_tabular_input( $self, 'csv' );
}

sub _prepare_tabular_input {
    my ( $self, $format ) = @_;
    my $source = source_adapter( $self, $format )->load;

    $self->{data} = $source->data;
    $self->{_owns_prepared_data} = 1 if $source->owned;
    $self->{data_redcap_dict} = $source->artifact('redcap_dictionary')
      if defined $source->artifact('redcap_dictionary');
    $self->{data_mapping_file} = $source->artifact('entity_mapping');

    my $loaded_mapping_file = $source->artifact('mapping');
    $self->{metaData}     = get_metaData($self);
    $self->{convertPheno} = get_info($self);
    $self->{mapping_file_derived_entity_overrides} =
      _mapping_file_derived_entity_overrides($loaded_mapping_file);

    return 1;
}

sub _prepare_omop2bff_input {
    my ($self) = @_;
    return 1 if $self->{omop_input_prepared} && exists $self->{data};

    # Keep caller-owned table data reusable. The OMOP source adapter clones
    # this input before cache construction and participant grouping consume it.
    $self->{_omop_source_data} = $self->{data}
      if exists $self->{data} && !exists $self->{_omop_source_data};
    $self->{data} = $self->{_omop_source_data}
      if !exists $self->{data} && exists $self->{_omop_source_data};

    $self->{method_ori} =
      exists $self->{method_ori} ? $self->{method_ori} : 'omop2bff';
    _ensure_omop_specimen_table_for_biosamples($self);
    $self->{prev_omop_tables} = [ @{ $self->{omop_tables} } ];

    my $source = _omop_collect_input($self);
    my $data   = $source->data;

    _omop_require_core_tables( $self, $data );
    _require_omop_specimen_for_biosamples(
        $self,
        $data,
        {
            filepath_sql  => $source->artifact('filepath_sql'),
            filepaths_csv => $source->artifact('filepaths_csv'),
        }
    );
    _omop_init_caches_and_metadata( $self, $data );
    _omop_prepare_data_shape( $self, $data );
    $self->{_owns_prepared_data} = 1 if $source->owned;

    $self->{filepath_sql} = $source->artifact('filepath_sql')
      if defined $source->artifact('filepath_sql');
    $self->{filepaths_csv} = $source->artifact('filepaths_csv') || [];
    $self->{omop_input_prepared} = 1;

    return 1;
}

sub _prepare_openehr2bff_input {
    my ($self) = @_;
    return 1 if $self->{openehr_input_prepared};

    my $source = source_adapter( $self, 'openehr' )->load;
    my @documents = @{ $source->data };
    my $grouped   = _group_openehr_documents_by_patient( $self, \@documents );

    $self->{data} = @{$grouped} == 1 ? $grouped->[0] : $grouped;
    $self->{_owns_prepared_data} = 1 if $source->owned;
    $self->{convertPheno} ||= get_info($self);
    $self->{openehr_input_prepared} = 1;

    return 1;
}

sub _group_openehr_documents_by_patient {
    my ( $self, $documents ) = @_;

    my %by_patient;
    my @order;

    for my $doc ( @{$documents} ) {
        for my $patient_doc ( _split_openehr_document_by_patient( $self, $doc ) ) {
        my $patient_id =
          Convert::Pheno::OpenEHR::ToBFF::resolve_openehr_patient_id( $self, $patient_doc );

        die "The input <openEHR> data could not be resolved to a patient id; please provide one composition set with a stable patient identifier in the payload or envelope\n"
          unless defined $patient_id && length $patient_id;

        if ( !exists $by_patient{$patient_id} ) {
            $by_patient{$patient_id} = {
                patient      => { id => $patient_id },
                compositions => [],
            };
            push @order, $patient_id;
        }

        push @{ $by_patient{$patient_id}{compositions} },
          @{
            Convert::Pheno::OpenEHR::ToBFF::extract_openehr_compositions($patient_doc);
          };
        }
    }

    return [ map { $by_patient{$_} } @order ];
}

sub _split_openehr_document_by_patient {
    my ( $self, $doc ) = @_;

    return ($doc) if _openehr_document_has_patient_context($doc);

    my $compositions =
      Convert::Pheno::OpenEHR::ToBFF::extract_openehr_compositions($doc);
    return ($doc) unless ref($compositions) eq 'ARRAY' && @{$compositions} > 1;

    my %by_patient;
    my @order;
    my $missing = 0;

    for my $composition ( @{$compositions} ) {
        my $patient_id = Convert::Pheno::OpenEHR::ToBFF::resolve_openehr_embedded_patient_id(
            $composition,
            [$composition]
        );

        if ( !defined $patient_id || !length $patient_id ) {
            $missing = 1;
            next;
        }

        if ( !exists $by_patient{$patient_id} ) {
            $by_patient{$patient_id} = [];
            push @order, $patient_id;
        }
        push @{ $by_patient{$patient_id} }, $composition;
    }

    return ($doc) unless @order > 1;

    die "The input <openEHR> data mixes patient-identified and unidentified compositions; please provide patient-bearing envelopes or per-patient composition sets\n"
      if $missing;

    return map {
        {
            patient      => { id => $_ },
            compositions => $by_patient{$_},
        }
    } @order;
}

sub _openehr_document_has_patient_context {
    my ($doc) = @_;
    return 0 unless ref($doc) eq 'HASH';

    # Keep only explicitly patient-scoped envelope identifiers here.
    # Top-level envelope ids such as <id> or <ehr_id> are accepted later as
    # fallback patient identifiers, but they must not suppress per-composition
    # splitting when distinct embedded patient ids are present.
    return 1
      if exists $doc->{patient}
      && ref( $doc->{patient} ) eq 'HASH'
      && defined $doc->{patient}{id}
      && length $doc->{patient}{id};

    return 1
      if exists $doc->{ehr_status}
      && ref( $doc->{ehr_status} ) eq 'HASH'
      && exists $doc->{ehr_status}{subject};

    return 0;
}

sub _omop_requests_biosamples {
    my ($self) = @_;
    return scalar grep { $_ eq 'biosamples' } @{ $self->{entities} || [] };
}

sub _ensure_omop_specimen_table_for_biosamples {
    my ($self) = @_;
    return 1 unless _omop_requests_biosamples($self);
    return 1 if grep { $_ eq 'SPECIMEN' } @{ $self->{omop_tables} || [] };

    $self->{omop_tables} = [ @{ $self->{omop_tables} || [] }, 'SPECIMEN' ];
    return 1;
}

sub _require_omop_specimen_for_biosamples {
    my ( $self, $data, $ctx ) = @_;
    return 1 unless _omop_requests_biosamples($self);
    return 1 if exists $data->{SPECIMEN};
    return 1 if _omop_stream_source_has_specimen( $self, $ctx );

    die "The entity <biosamples> requires the OMOP table <SPECIMEN>\n";
}

sub _omop_stream_source_has_specimen {
    my ( $self, $ctx ) = @_;
    return 0 unless $self->{stream};
    return 0 unless defined $ctx && ref($ctx) eq 'HASH';

    if ( defined $ctx->{filepath_sql} && length $ctx->{filepath_sql} ) {
        return scalar grep { $_ eq 'SPECIMEN' } @{ $self->{prev_omop_tables} || [] };
    }

    for my $file ( @{ $ctx->{filepaths_csv} || [] } ) {
        return 1 if $file =~ m{(?:^|/|\\)SPECIMEN\.(?:csv|tsv)(?:\.gz)?$}i;
    }

    return 0;
}

sub _mapping_file_derived_entity_overrides {
    my ($mapping) = @_;
    return {} unless defined $mapping && ref($mapping) eq 'HASH';
    return {} unless exists $mapping->{project} && ref( $mapping->{project} ) eq 'HASH';

    my $project = $mapping->{project};
    my %overrides;

    if ( defined $project->{id} ) {
        $overrides{datasets}{id}   = $project->{id};
        $overrides{datasets}{name} = $project->{id};
        $overrides{cohorts}{id}    = $project->{id} . '-cohort';
        $overrides{cohorts}{name}  = $project->{id};
    }

    $overrides{datasets}{description} = $project->{description}
      if defined $project->{description};
    $overrides{datasets}{version} = $project->{version}
      if defined $project->{version};

    if ( exists $mapping->{beacon} && ref( $mapping->{beacon} ) eq 'HASH' ) {
        _merge_hash_into(
            $overrides{datasets},
            $mapping->{beacon}{datasets}{defaults}
          )
          if exists $mapping->{beacon}{datasets}
          && ref( $mapping->{beacon}{datasets} ) eq 'HASH'
          && ref( $mapping->{beacon}{datasets}{defaults} ) eq 'HASH';
        _merge_hash_into(
            $overrides{cohorts},
            $mapping->{beacon}{cohorts}{defaults}
          )
          if exists $mapping->{beacon}{cohorts}
          && ref( $mapping->{beacon}{cohorts} ) eq 'HASH'
          && ref( $mapping->{beacon}{cohorts}{defaults} ) eq 'HASH';
    }

    return \%overrides;
}

sub _merge_hash_into {
    my ( $target, $source ) = @_;
    return $target unless defined $source && ref($source) eq 'HASH';
    $target ||= {};

    for my $key ( keys %{$source} ) {
        my $value = $source->{$key};

        if ( ref($value) eq 'HASH' ) {
            $target->{$key} ||= {};
            _merge_hash_into( $target->{$key}, $value );
            next;
        }

        if ( ref($value) eq 'ARRAY' ) {
            $target->{$key} = [ @{$value} ];
            next;
        }

        $target->{$key} = $value;
    }

    return $target;
}

sub _with_temp_self_fields {
    my ( $self, $fields, $code ) = @_;

    my %state;
    for my $field ( keys %{$fields} ) {
        $state{$field} = {
            had   => exists $self->{$field} ? 1 : 0,
            value => exists $self->{$field} ? $self->{$field} : undef,
        };
        $self->{$field} = $fields->{$field};
    }

    my ( $ok, $err, @ret );
    my $wantarray = wantarray;
    $ok = eval {
        if ( !defined $wantarray ) {
            $code->();
            @ret = ();
        }
        elsif ($wantarray) {
            @ret = $code->();
        }
        else {
            $ret[0] = $code->();
        }
        1;
    };
    $err = $@;

    for my $field ( keys %{$fields} ) {
        if ( $state{$field}{had} ) {
            $self->{$field} = $state{$field}{value};
        }
        else {
            delete $self->{$field};
        }
    }

    die $err unless $ok;
    return if !defined $wantarray;
    return $wantarray ? @ret : $ret[0];
}

sub _transform_item {
    return transform_item(@_);
}

sub omop_dispatcher {
    return Convert::Pheno::Emit::OMOP::omop_dispatcher(@_);
}

sub omop_stream_dispatcher {
    return Convert::Pheno::OMOP::ParticipantStream::omop_stream_dispatcher(@_);
}

sub process_csv_files_stream {
    return Convert::Pheno::OMOP::ParticipantStream::process_csv_files_stream(@_);
}

sub process_sqldump_stream {
    return Convert::Pheno::OMOP::ParticipantStream::process_sqldump_stream(@_);
}

sub omop2bff_stream_processing {
    my ( $self, $data ) = @_;
    return Convert::Pheno::OMOP::ToBFF::run_omop_to_bundle(
        $self, $data, $self->{conversion_context}
      )
      if omop_streams_multiple_entities($self);

    return do_omop2bff( $self, $data );
}

sub omop_stream_targets_open_wrapper {
    return omop_stream_targets_open(@_);
}

sub omop_stream_targets_write_wrapper {
    return omop_stream_targets_write(@_);
}

sub omop_stream_targets_finalize_wrapper {
    return omop_stream_targets_finalize(@_);
}

sub omop_streams_multiple_entities_wrapper {
    return omop_streams_multiple_entities(@_);
}

sub Dumper_concise {
    {
        local $Data::Dumper::Terse     = 1;
        local $Data::Dumper::Indent    = 1;
        local $Data::Dumper::Useqq     = 1;
        local $Data::Dumper::Deparse   = 1;
        local $Data::Dumper::Quotekeys = 1;
        local $Data::Dumper::Sortkeys  = 1;
        local $Data::Dumper::Pair      = ' : ';
        print Dumper shift;
    }
}

1;

=head1 NAME

Convert::Pheno - Convert clinical and phenotypic data between supported models

=head1 SYNOPSIS

 use Convert::Pheno;

 my $pxf = {
     "phenopacket" => {
         "id"      => "P0007500",
         "subject" => {
             "id"          => "P0007500",
             "dateOfBirth" => "2000-01-01T00:00:00Z",
             "sex"         => "FEMALE"
         }
     }
 };

 my $converter = Convert::Pheno->new(
     {
         data   => $pxf,
         method => 'pxf2bff'
     }
 );

 my $individual = $converter->pxf2bff;

=head1 DESCRIPTION

C<Convert::Pheno> is the conversion engine used by the C<convert-pheno>
command-line program. It converts supported in-memory data structures and
route-specific file inputs between Beacon v2 Models Format (BFF),
Phenopackets v2 (PXF), OMOP-CDM, REDCap, cBioPortal clinical study packages,
CDISC-ODM, CDISC Dataset-JSON, CDISC Dataset-XML, FHIR R4, openEHR, and tabular
representations.

Conversion availability and required arguments depend on the selected route.
Mapping-file conversions use the Mapping V2 contract and require
C<mappingVersion: 2>; pre-V2 mapping files are rejected.

=head1 METHODS

=head2 new

 my $converter = Convert::Pheno->new(\%arguments);

Creates a converter. C<method> identifies the public conversion method.
In-memory routes receive decoded input under C<data>; file-based routes use
the arguments documented for that conversion.

=head2 Conversion methods

 my $result = $converter->$method;

In-memory conversions return Perl data structures. Streaming and file-output
routes write to their configured destinations and may instead return a
completion status. See the module guide for supported methods, arguments,
multi-entity results, and Python interoperability.

=head1 DOCUMENTATION

=over

=item Project documentation

L<https://cnag-biomedical-informatics.github.io/convert-pheno>

=item Module usage

L<https://cnag-biomedical-informatics.github.io/convert-pheno/use-as-a-module>

=item Command-line interface

L<https://cnag-biomedical-informatics.github.io/convert-pheno/use-as-a-command-line-interface>

=back

=head1 ERRORS

Invalid input, unsupported routes, and conversion failures raise exceptions.
Callers that need recovery should invoke conversion methods inside C<eval> or
another exception-handling mechanism.

=head1 CITATION

Please cite the following reference in published work that uses
C<Convert-Pheno>:

Rueda, M et al., (2024). Convert-Pheno: A software toolkit for the interconversion of standard data models for phenotypic data. Journal of Biomedical Informatics. L<DOI|https://doi.org/10.1016/j.jbi.2023.104558>

=head1 AUTHOR

Written by Manuel Rueda, PhD. Info about CNAG can be found at L<https://www.cnag.eu>.

=head1 COPYRIGHT AND LICENSE

Copyright 2022-2026 Manuel Rueda and CNAG.

This software is distributed under the Artistic License 2.0. See the LICENSE
file included in this distribution.

=cut
