#!/usr/bin/env perl
use strict;
use warnings;

use lib qw(./lib ../lib t/lib);
use Test::More;
use Test::Exception;
use File::Temp qw(tempfile);
use Convert::Pheno;
use Convert::Pheno::ConversionRequest;
use Convert::Pheno::ExecutionContext;
use Convert::Pheno::Runner qw(resolve_operation run_operation);

my $orig_warn_handler = $SIG{__WARN__};
local $SIG{__WARN__} = sub {
    return if $_[0] =~ /^Subroutine .* redefined /;
    return $orig_warn_handler ? $orig_warn_handler->(@_) : warn @_;
};

{
    my $convert = Convert::Pheno->new( {} );
    is( $convert->search, 'exact', 'module defaults ontology search to exact' );
    is( $convert->text_similarity_method, 'cosine', 'module defaults text similarity to cosine' );
    is( $convert->min_text_similarity_score, 0.8, 'module supplies the documented similarity threshold' );
    is( $convert->levenshtein_weight, 0.1, 'module supplies the documented Levenshtein weight' );
}

{
    no warnings 'redefine';

    local *Convert::Pheno::redcap2bff = sub { return [ { id => 'r1' } ] };
    local *Convert::Pheno::cdiscodm2bff = sub { return [ { id => 'c1' } ] };
    local *Convert::Pheno::datasetjson2bff = sub { return [ { id => 'd1' } ] };
    local *Convert::Pheno::fhir2bff     = sub { return [ { id => 'f1' } ] };
    local *Convert::Pheno::csv2bff    = sub { return [ { id => 'csv1' } ] };
    local *Convert::Pheno::pxf2bff    = sub { return [ { id => 'p1' } ] };
    local *Convert::Pheno::omop2bff   = sub { return [ { id => 'o1' } ] };
    local *Convert::Pheno::_run_primary_view = sub {
        my ($self) = @_;
        return {
            method      => $self->{method},
            data        => $self->{data},
            in_textfile => $self->{in_textfile},
        };
    };
    local *Convert::Pheno::merge_omop_tables = sub { return { merged => shift } };

    my $redcap_converter = Convert::Pheno->new(
        { method => 'redcap2pxf', in_textfile => 1 }
    );
    my $redcap = $redcap_converter->redcap2pxf;
    is( $redcap->{method}, 'bff2pxf', 'redcap2pxf switches to bff2pxf' );
    is_deeply( $redcap->{data}, [ { id => 'r1' } ], 'redcap2pxf forwards redcap2bff output' );
    is( $redcap->{in_textfile}, 0, 'redcap2pxf forces in_textfile off' );
    is(
        $redcap_converter->{method},
        'redcap2pxf',
        'compound conversion does not rewrite the caller converter method'
    );
    ok(
        !exists $redcap_converter->{data},
        'compound conversion does not attach intermediate data to the caller converter'
    );

    my $redcap_omop =
      Convert::Pheno->new( { method => 'redcap2omop', in_textfile => 1 } )
      ->redcap2omop;
    is(
        $redcap_omop->{merged}{data}[0]{id},
        'r1',
        'redcap2omop merges redcap2bff output',
    );

    my $cdisc = Convert::Pheno->new( { method => 'cdiscodm2omop', in_textfile => 1 } )->cdiscodm2omop;
    is( $cdisc->{merged}{data}[0]{id}, 'c1', 'cdiscodm2omop merges cdiscodm2bff output' );

    my $datasetjson = Convert::Pheno->new( { method => 'datasetjson2pxf', in_textfile => 1 } )->datasetjson2pxf;
    is( $datasetjson->{data}[0]{id}, 'd1', 'datasetjson2pxf forwards datasetjson2bff output' );

    my $datasetjson_omop =
      Convert::Pheno->new( { method => 'datasetjson2omop', in_textfile => 1 } )
      ->datasetjson2omop;
    is(
        $datasetjson_omop->{merged}{data}[0]{id},
        'd1',
        'datasetjson2omop merges datasetjson2bff output'
    );

    my $fhir = Convert::Pheno->new( { method => 'fhir2pxf', in_textfile => 1 } )->fhir2pxf;
    is( $fhir->{data}[0]{id}, 'f1', 'fhir2pxf forwards fhir2bff output' );

    my $fhir_omop = Convert::Pheno->new( { method => 'fhir2omop', in_textfile => 1 } )->fhir2omop;
    is(
        $fhir_omop->{merged}{data}[0]{id},
        'f1',
        'fhir2omop merges fhir2bff output'
    );

    my $csv = Convert::Pheno->new( { method => 'csv2omop', in_textfile => 1 } )->csv2omop;
    is( $csv->{merged}{data}[0]{id}, 'csv1', 'csv2omop merges csv2bff output' );

    my $pxf = Convert::Pheno->new( { method => 'pxf2omop', in_textfile => 1 } )->pxf2omop;
    is( $pxf->{merged}{data}[0]{id}, 'p1', 'pxf2omop merges pxf2bff output' );
}

{
    my $input = [ { id => 'source-1' } ];
    my $convert = Convert::Pheno->new(
        {
            method   => 'csv2omop',
            data     => $input,
            entities => ['individuals'],
        }
    );
    my $request = Convert::Pheno::ConversionRequest->from_converter($convert);

    is( $request->method, 'csv2omop', 'conversion request keeps the public method' );
    is_deeply(
        $request->pipeline,
        [ 'csv2bff', 'bff2omop' ],
        'conversion request exposes immutable registry stages'
    );
    throws_ok(
        sub { $request->{method} = 'bff2omop' },
        qr/read-only/,
        'conversion request top-level state is immutable'
    );

    my $execution = Convert::Pheno::ExecutionContext->new(
        { request => $request }
    );
    my ( $first_stage, $first_args ) = $execution->begin_next_stage;
    is( $first_stage, 'csv2bff', 'execution context starts the first stage' );
    is( $first_args->{data}, $input, 'first stage receives caller-owned input by reference' );

    my $intermediate = [ { id => 'bff-1' } ];
    $execution->complete_stage($intermediate);
    my ( $second_stage, $second_args ) = $execution->begin_next_stage;
    is( $second_stage, 'bff2omop', 'execution context advances to the second stage' );
    is( $second_args->{data}, $intermediate, 'second stage receives the prior stage result' );
    is( $second_args->{in_textfile}, 0, 'intermediate stages use in-memory input' );
}

{
    my ( undef, $tmp_file ) = tempfile();
    my $convert = Convert::Pheno->new(
        {
            method    => 'omop2bff',
            out_file  => $tmp_file,
            in_textfile => 0,
        }
    );
    $convert->{omop_cli} = 1;
    my $stream = Convert::Pheno::_dispatcher_open_stream_out($convert);
    ok( $stream->{fh}, '_dispatcher_open_stream_out opens output file in streaming mode' );
    close $stream->{fh};
}

{
    my $convert = Convert::Pheno->new(
        {
            method      => 'bff2pxf',
            in_textfile => 0,
            data        => { inline => 1 },
        }
    );
    is_deeply( Convert::Pheno::_dispatcher_input_data($convert), { inline => 1 }, '_dispatcher_input_data returns in-memory data when not reading a file' );
}

{
    my $convert = Convert::Pheno->new(
        {
            method      => 'bff2pxf',
            in_textfile => 1,
            in_file     => 't/bff2pxf/in/individuals.json',
        }
    );
    my $data = Convert::Pheno::_dispatcher_input_data($convert);
    is( ref($data), 'ARRAY', '_dispatcher_input_data reads structured file input for bff-like methods' );
}

{
    my $data = Convert::Pheno::io_yaml_or_json(
        {
            filepath => 't/bff2pxf/in/individuals.json',
            mode     => 'read',
        }
    );
    my $before = JSON::XS->new->canonical->encode($data);
    my $convert = Convert::Pheno->new(
        {
            method => 'bff2pxf',
            data   => $data,
            test   => 1,
        }
    );

    my $result = $convert->bff2pxf;
    is( scalar @{$result}, scalar @{$data}, 'in-memory BFF conversion returns every input record' );
    is(
        JSON::XS->new->canonical->encode($data),
        $before,
        'in-memory BFF conversion does not modify caller-owned data'
    );
    is( $convert->{data}, $data, 'converter retains caller-owned data for reuse' );
}

{
    my $data = Convert::Pheno::io_yaml_or_json(
        {
            filepath => 't/pxf2bff/in/pxf.json',
            mode     => 'read',
        }
    );
    my $before = JSON::XS->new->canonical->encode($data);
    my $convert = Convert::Pheno->new(
        {
            method => 'pxf2bff',
            data   => $data,
            test   => 1,
        }
    );

    my $result = $convert->pxf2bff;
    is(
        scalar @{$result},
        scalar @{$data},
        'in-memory PXF conversion returns every input record'
    );
    is(
        JSON::XS->new->canonical->encode($data),
        $before,
        'in-memory PXF conversion does not modify caller-owned data'
    );
    is( $convert->{data}, $data, 'PXF converter retains caller-owned data for reuse' );
}

{
    my $convert = Convert::Pheno->new( { stream => 1 } );
    local *Convert::Pheno::transpose_omop_data_structure = sub { die 'should not be called' };
    my $data = { CONCEPT => [] };
    ok( Convert::Pheno::_omop_prepare_data_shape( $convert, $data ), '_omop_prepare_data_shape succeeds in stream mode' );
    is( $convert->{data}, $data, '_omop_prepare_data_shape keeps data as-is in stream mode' );
}

{
    my $convert = Convert::Pheno->new( { stream => 0 } );
    local *Convert::Pheno::transpose_omop_data_structure = sub { return [ { PERSON => { person_id => 1 } } ] };
    ok( Convert::Pheno::_omop_prepare_data_shape( $convert, { CONCEPT => [] } ), '_omop_prepare_data_shape succeeds in non-stream mode' );
    is_deeply( $convert->{data}, [ { PERSON => { person_id => 1 } } ], '_omop_prepare_data_shape transposes data in non-stream mode' );
}

{
    my $convert = Convert::Pheno->new( {} );
    $convert->{temporary_probe} = 'original';

    throws_ok(
        sub {
            Convert::Pheno::_with_temp_self_field(
                $convert,
                'temporary_probe',
                'temporary',
                sub { die "temporary callback failed\n" },
            );
        },
        qr/temporary callback failed/,
        '_with_temp_self_field preserves callback failures'
    );
    is(
        $convert->{temporary_probe},
        'original',
        '_with_temp_self_field restores object state after failure'
    );
}

{
    my $convert = Convert::Pheno->new( {} );
    dies_ok {
        Convert::Pheno::_omop_require_core_tables( $convert, {} )
    }
    'OMOP preparation dies when CONCEPT is missing';
    dies_ok {
        Convert::Pheno::_omop_require_core_tables( $convert, { CONCEPT => [] } )
    }
    'OMOP preparation dies when PERSON is missing';
    ok(
        Convert::Pheno::_omop_require_core_tables(
            $convert,
            { CONCEPT => [], PERSON => [] },
        ),
        'OMOP preparation accepts its core tables',
    );
}

{
    my $convert = Convert::Pheno->new( { method => 'omop2bff', prev_omop_tables => [ 'DRUG_EXPOSURE', 'PERSON' ], person => { 1 => { person_id => 1 } } } );
    my @csv_calls;
    my @sql_calls;
    local *Convert::Pheno::open_connections_SQLite = sub { return 1 };
    local *Convert::Pheno::read_csv_stream = sub { push @csv_calls, $_[0]{in}; return 1 };
    local *Convert::Pheno::read_sqldump_stream = sub { push @sql_calls, $_[0]{self}{omop_tables}[0]; return 1 };

    ok( Convert::Pheno::process_csv_files_stream( $convert, [ 'a.csv', 'b.csv' ] ), 'process_csv_files_stream succeeds' );
    is_deeply( \@csv_calls, [ 'a.csv', 'b.csv' ], 'process_csv_files_stream forwards each file' );

    ok( Convert::Pheno::process_sqldump_stream( $convert, 'dump.sql', [ 'CONCEPT', 'DRUG_EXPOSURE', 'PERSON' ] ), 'process_sqldump_stream succeeds' );
    is_deeply( \@sql_calls, [ 'DRUG_EXPOSURE' ], 'process_sqldump_stream skips RAM-memory OMOP tables' );
}

{
    no warnings 'redefine';

    my ( $closed, $audit_finalized, $stream_commit );
    my $convert = Convert::Pheno->new(
        {
            method           => 'omop2bff',
            prev_omop_tables => ['PERSON'],
        }
    );

    local *Convert::Pheno::open_connections_SQLite = sub { return 1 };
    local *Convert::Pheno::close_connections_SQLite = sub { $closed++; return 1 };
    local *Convert::Pheno::finalize_term_audit = sub { $audit_finalized++; return 1 };
    local *Convert::Pheno::omop_streams_multiple_entities_wrapper = sub { return 1 };
    local *Convert::Pheno::omop_stream_targets_open_wrapper = sub { return 1 };
    local *Convert::Pheno::omop_stream_targets_finalize_wrapper = sub {
        ( undef, $stream_commit ) = @_;
        return 1;
    };
    local *Convert::Pheno::OMOP::ParticipantStream::process_csv_files_stream =
      sub { die "stream mapping failed\n" };

    throws_ok(
        sub {
            Convert::Pheno::OMOP::ParticipantStream::omop_stream_dispatcher(
                {
                    self      => $convert,
                    filepaths => ['table.csv'],
                }
            );
        },
        qr/stream mapping failed/,
        'stream dispatcher preserves conversion failures'
    );
    is( $stream_commit, 0, 'failed streams discard staged entity outputs' );
    is( $closed, 1, 'stream dispatcher closes SQLite connections after failure' );
    is( $audit_finalized, 1, 'stream dispatcher finalizes terminology audits after failure' );
}

{
    no warnings 'redefine';
    my $json = JSON::XS->new->canonical->pretty;
    my $convert = Convert::Pheno->new( {} );
    $convert->{method_ori} = 'omop2pxf';
    local *Convert::Pheno::do_bff2pxf = sub { return { converted => 1 } };
    my $out_ref = Convert::Pheno::omop_dispatcher( $convert, { id => 1 }, $json );
    like( $$out_ref, qr/converted/, 'omop_dispatcher applies bff2pxf when method_ori is omop2pxf' );

    $convert->{method_ori} = 'omop2bff';
    $out_ref = Convert::Pheno::omop_dispatcher( $convert, { direct => 1 }, $json );
    like( $$out_ref, qr/direct/, 'omop_dispatcher encodes original result otherwise' );
}

{
    no warnings 'redefine';

    my $convert = Convert::Pheno->new( { method => 'omop2bff' } );
    my $op = resolve_operation($convert);
    is( $op->{type}, 'bundle', 'runner resolves omop2bff as a bundle operation' );
    ok( $op->{requires_sqlite}, 'runner marks omop2bff as requiring SQLite' );
    is_deeply( $op->{default_entities}, ['individuals'], 'runner resolves omop2bff default bundle entities' );

    local *Convert::Pheno::OMOP::ToBFF::run_omop_to_bundle = sub {
        my ( $self, $input, $context ) = @_;
        my $bundle = Convert::Pheno::Model::Bundle->new( { entities => ['individuals'] } );
        $bundle->add_entity( individuals => { id => $input->{id} } );
        return $bundle;
    };

    local *Convert::Pheno::open_connections_SQLite       = sub { return 1 };
    local *Convert::Pheno::close_connections_SQLite      = sub { return 1 };
    local *Convert::Pheno::finalize_term_audit           = sub { return 1 };
    local *Convert::Pheno::_dispatcher_open_stream_out   = sub { return undef };

    my $res = run_operation( $convert, { id => 'bundle-1' }, operation => $op, view => 'primary' );
    is_deeply( $res, { id => 'bundle-1' }, 'runner unwraps bundle operations to the primary view' );
}

{
    my $convert = Convert::Pheno->new( { method => 'bff2csv' } );
    my $op = resolve_operation($convert);
    is( $op->{type}, 'direct', 'runner resolves bff2csv as a direct operation' );
    ok( !$op->{requires_sqlite}, 'runner does not open SQLite for bff2csv' );
}

{
    my $convert = Convert::Pheno->new( { method => 'pxf2bff' } );
    my $op = resolve_operation($convert);
    ok( $op->{requires_sqlite}, 'runner keeps SQLite available for PXF ontology mapping' );
}

{
    no warnings 'redefine';

    my $closed    = 0;
    my $finalized = 0;
    my $convert   = Convert::Pheno->new( { method => 'csv2bff' } );
    my $operation = {
        type            => 'direct',
        requires_sqlite => 1,
        run             => sub { die "mapping failed\n" },
    };

    local *Convert::Pheno::open_connections_SQLite = sub { return 1 };
    local *Convert::Pheno::close_connections_SQLite = sub {
        $closed++;
        return 1;
    };
    local *Convert::Pheno::finalize_term_audit = sub {
        $finalized++;
        return 1;
    };
    local *Convert::Pheno::_dispatcher_open_stream_out = sub { return undef };

    throws_ok(
        sub { run_operation( $convert, {}, operation => $operation ) },
        qr/mapping failed/,
        'runner preserves conversion failures'
    );
    is( $closed, 1, 'runner closes SQLite connections after a conversion failure' );
    is( $finalized, 1, 'runner finalizes terminology audit output after a conversion failure' );
    ok( !exists $convert->{current_row}, 'runner clears transient row state after a conversion failure' );
}

{
    no warnings 'redefine';

    my $convert = Convert::Pheno->new(
        {
            method   => 'omop2bff',
            entities => [ 'individuals', 'biosamples' ],
        }
    );

    local *Convert::Pheno::open_connections_SQLite       = sub { return 1 };
    local *Convert::Pheno::close_connections_SQLite      = sub { return 1 };
    local *Convert::Pheno::finalize_term_audit           = sub { return 1 };
    local *Convert::Pheno::_dispatcher_open_stream_out   = sub { return undef };

    local *Convert::Pheno::OMOP::ToBFF::run_omop_to_bundle = sub {
        my ( $self, $input, $context ) = @_;
        my $bundle = Convert::Pheno::Model::Bundle->new(
            {
                context  => $context,
                entities => $context->entities,
            }
        );
        $bundle->add_entity( individuals => { id => $input->{id} } );
        $bundle->add_entity( biosamples  => { id => "bio-$input->{id}" } );
        return $bundle;
    };

    my $bundle = run_operation(
        $convert,
        [ { id => 'i1' }, { id => 'i2' } ],
        view => 'bundle',
    );

    is_deeply(
        $bundle->entities('individuals'),
        [ { id => 'i1' }, { id => 'i2' } ],
        'runner merges individuals across bundle operations'
    );
    is_deeply(
        $bundle->entities('biosamples'),
        [ { id => 'bio-i1' }, { id => 'bio-i2' } ],
        'runner merges secondary entities across bundle operations'
    );
}

{
    no warnings 'redefine';

    my $convert = Convert::Pheno->new(
        {
            method       => 'omop2bff',
            entities     => [ 'datasets', 'cohorts' ],
            convertPheno => { version => '0.29_1', beaconSchemaVersion => '2.0.0' },
        }
    );

    local *Convert::Pheno::open_connections_SQLite     = sub { return 1 };
    local *Convert::Pheno::close_connections_SQLite    = sub { return 1 };
    local *Convert::Pheno::finalize_term_audit         = sub { return 1 };
    local *Convert::Pheno::_dispatcher_open_stream_out = sub { return undef };

    local *Convert::Pheno::OMOP::ToBFF::run_omop_to_bundle = sub {
        my ( $self, $input, $context ) = @_;
        my $bundle = Convert::Pheno::Model::Bundle->new(
            {
                context  => $context,
                entities => $context->entities,
            }
        );
        $bundle->add_entity( individuals => $input );
        return $bundle;
    };

    my $bundle = run_operation(
        $convert,
        [
            { id => 'i1', diseases => [ { diseaseCode => { id => 'NCIT:C1' } } ] },
            { id => 'i2' },
        ],
        view => 'bundle',
    );

    is(
        $bundle->entities('datasets')->[0]{id},
        'dataset-1',
        'runner synthesizes a dataset from individuals in bundle mode'
    );
    is(
        $bundle->entities('cohorts')->[0]{cohortSize},
        2,
        'runner synthesizes a cohort with the correct size'
    );
    is(
        $bundle->entities('cohorts')->[0]{cohortDataTypes}[0]{id},
        'OGMS:0000015',
        'runner infers cohort data types from individual content'
    );
}

done_testing();
