package DBIx::Fast::Profiler;

use v5.38;
use Object::Pad 0.807;
use Time::HiRes 'time';
use DBIx::Fast::Output;
# Cpanel::JSON::XS and Term::ANSIColor are optional (recommends): the _color /
# _encode_json helpers below load them on demand and fall back to colorless
# output and core JSON::PP when absent.

class DBIx::Fast::Profiler :isa(DBIx::Fast::Base) :does(DBIx::Fast::Output) {

    # Display helpers are runtime RECOMMENDS: text output must degrade
    # gracefully on a bare install - core JSON::PP for params, colorless
    # output without Term::ANSIColor. Resolved once, lazily.
    sub _color (@args) {
        state $impl = eval { require Term::ANSIColor; \&Term::ANSIColor::color }
                   || sub { '' };
        return $impl->(@args);
    }

    sub _encode_json ($data) {
        state $impl = eval { require Cpanel::JSON::XS; \&Cpanel::JSON::XS::encode_json }
                   || do { require JSON::PP; \&JSON::PP::encode_json };
        return $impl->($data);
    }
    field $auto_print  :param :accessor = 1;
    field $queries     :accessor        = [];
    field $enabled     :param :accessor = 1;
    field $slow_query  :param :accessor = 1.0;
    field $max_queries :param :accessor = 1000;

    method add_query ($sql, $params, $start_time, $end_time) {
        return unless $enabled;

        my $duration   = $end_time - $start_time;
        my $query_info = {
            sql      => $sql,
            params   => $params,
            start    => $start_time,
            end      => $end_time,
            duration => $duration,
            # The stack is only ever shown for slow queries (print_query), so
            # only pay for the caller() walk then; '' (defined) otherwise.
            stack    => ( $duration >= $slow_query )
                          ? $self->_get_stack_trace() : '',
            time     => scalar( localtime($start_time) )
        };

        if ( @{$queries} >= $max_queries ) {
            shift @{$queries};
        }

        push @{$queries}, $query_info;

        $self->print_query($query_info) if $auto_print;
    }

    # Envelope identity for DBIx::Fast::Output events.
    method _output_meta () { { source => 'tracker' } }

    # slow/warn/ok mirror the red/yellow/green duration buckets.
    method _severity ($duration) {
        return $duration >= $slow_query         ? 'slow'
             : $duration >= ( $slow_query / 2 ) ? 'warn'
             :                                    'ok';
    }

    method print_query ($query_info) {
        my $severity = $self->_severity( $query_info->{duration} );

        # json/callback mode: emit the raw record and skip text rendering.
        return if $self->emit( query => { %$query_info, severity => $severity } );

        my $duration_color =
            $severity eq 'slow' ? 'red'
          : $severity eq 'warn' ? 'yellow'
          :                       'green';

        $self->out( "\n", "=" x 80, "\n" );
        $self->out( _color('bold blue'), "Query at ", $query_info->{time},
          _color('reset'), "\n" );

        $self->out( _color('bold'), "Duration: ",
          _color($duration_color),
          sprintf( "%.6fs", $query_info->{duration} ),
          _color('reset'), "\n" );

        # Strip control characters (except \n/\t) before printing: SQL text can
        # embed ANSI escapes that would manipulate the reader's terminal.
        ( my $safe_sql = $self->_format_sql( $query_info->{sql} ) )
          =~ s/[^\P{Cc}\n\t]//g;
        $self->out( _color('bold cyan'), "SQL: ", _color('reset'), $safe_sql, "\n" );

        if ( @{ $query_info->{params} } ) {
            $self->out( _color('bold magenta'), "Params: ", _color('reset'),
              _encode_json( $query_info->{params} ), "\n" );
        }

        if ( $query_info->{duration} >= $slow_query ) {
            $self->out( _color('red'), "Stack Trace:\n", _color('reset'),
              $query_info->{stack}, "\n" );
        }
    }

    method _format_sql ($sql) {
        # Single precompiled regex with alternation - avoids recompiling
        # one regex per keyword on every call.
        state $kw_re = qr/\b(SELECT|FROM|WHERE|INSERT|UPDATE|DELETE|JOIN|ON|GROUP|BY|HAVING|ORDER|LIMIT)\b/;
        $sql =~ s/$kw_re/\n$1/g;
        return $sql;
    }

    method get_detailed_stats () {
        my %stats;
        for my $query ( @{$queries} ) {
            my $type = $self->_get_query_type( $query->{sql} );

            $stats{$type}->{count}++;
            $stats{$type}->{total_time} += $query->{duration};
            $stats{$type}->{avg_time} =
              $stats{$type}->{total_time} / $stats{$type}->{count};

            if (  !defined $stats{$type}->{min_time}
                || $query->{duration} < $stats{$type}->{min_time} )
            {
                $stats{$type}->{min_time} = $query->{duration};
            }

            if (  !defined $stats{$type}->{max_time}
                || $query->{duration} > $stats{$type}->{max_time} )
            {
                $stats{$type}->{max_time} = $query->{duration};
            }
        }

        return \%stats;
    }

    method _get_query_type ($sql) {
        return 'SELECT' if $sql =~ /^\s*SELECT/i;
        return 'INSERT' if $sql =~ /^\s*INSERT/i;
        return 'UPDATE' if $sql =~ /^\s*UPDATE/i;
        return 'DELETE' if $sql =~ /^\s*DELETE/i;
        return 'CREATE' if $sql =~ /^\s*CREATE/i;
        return 'ALTER'  if $sql =~ /^\s*ALTER/i;
        return 'DROP'   if $sql =~ /^\s*DROP/i;
        return 'OTHER';
    }

    method print_summary () {
        my $stats = $self->get_detailed_stats();
        return if $self->emit( summary => $stats );


        $self->out( "\n", "=" x 80, "\n" );
        $self->out( _color('bold'), "Query Statistics Summary\n", _color('reset') );
        $self->out( "=" x 80, "\n" );

        for my $type ( sort keys %$stats ) {
            $self->out( _color('bold blue'), "\n$type Queries:\n", _color('reset') );
            $self->outf( "  Count: %d\n",        $stats->{$type}->{count} );
            $self->outf( "  Total Time: %.6fs\n", $stats->{$type}->{total_time} );
            $self->outf( "  Avg Time: %.6fs\n",   $stats->{$type}->{avg_time} );
            $self->outf( "  Min Time: %.6fs\n",   $stats->{$type}->{min_time} );
            $self->outf( "  Max Time: %.6fs\n",   $stats->{$type}->{max_time} );
        }
    }

    method _get_stack_trace () {
        my $trace = "";
        my $i     = 0;

        while ( my @caller = caller( $i++ ) ) {
            next if $caller[0] =~ /^DBIx::Fast/;
            $trace .= sprintf( "%s line %d\n", $caller[1], $caller[2] );
        }

        return $trace;
    }

    method get_stats () {
        my $stats = {
            total_queries     => 0,
            total_time        => 0,
            avg_time          => 0,
            min_time          => undef,
            max_time          => 0,
            slow_queries      => 0,
            query_types       => {},
            last_query_time   => 0,
            queries_per_second => 0
        };

        return $stats unless @{$queries};

        my $first_query_time = $queries->[0]->{start};
        my $last_query_time  = $queries->[-1]->{end};

        for my $query ( @{$queries} ) {
            $stats->{total_queries}++;
            $stats->{total_time} += $query->{duration};

            if ( !defined $stats->{min_time}
                || $query->{duration} < $stats->{min_time} )
            {
                $stats->{min_time} = $query->{duration};
            }

            if ( $query->{duration} > $stats->{max_time} ) {
                $stats->{max_time} = $query->{duration};
            }

            $stats->{slow_queries}++ if $query->{duration} > $slow_query;

            my $type = $self->_get_query_type( $query->{sql} );
            $stats->{query_types}->{$type} ||= {
                count      => 0,
                total_time => 0,
                min_time   => undef,
                max_time   => 0,
                avg_time   => 0
            };

            my $type_stats = $stats->{query_types}->{$type};
            $type_stats->{count}++;
            $type_stats->{total_time} += $query->{duration};

            if ( !defined $type_stats->{min_time}
                || $query->{duration} < $type_stats->{min_time} )
            {
                $type_stats->{min_time} = $query->{duration};
            }

            if ( $query->{duration} > $type_stats->{max_time} ) {
                $type_stats->{max_time} = $query->{duration};
            }

            $type_stats->{avg_time} =
              $type_stats->{total_time} / $type_stats->{count};
        }

        $stats->{avg_time} = $stats->{total_time} / $stats->{total_queries};

        my $total_elapsed = $last_query_time - $first_query_time;
        if ( $total_elapsed > 0 ) {
            $stats->{queries_per_second} =
              $stats->{total_queries} / $total_elapsed;
        }

        $stats->{last_query_time} = $last_query_time;

        $stats->{slow_query_percentage} =
          ( $stats->{slow_queries} / $stats->{total_queries} ) * 100;

        return $stats;
    }

    method print_stats () {
        my $stats = $self->get_stats();
        return if $self->emit( stats => $stats );


        $self->out( "\n", "=" x 80, "\n" );
        $self->out( _color('bold'), "Query Statistics\n", _color('reset') );
        $self->out( "=" x 80, "\n\n" );

        $self->outf( "Total Queries: %d\n",  $stats->{total_queries} );
        $self->outf( "Total Time: %.4fs\n",  $stats->{total_time} );
        $self->outf( "Average Time: %.4fs\n", $stats->{avg_time} );
        $self->outf( "Min Time: %.4fs\n",     $stats->{min_time} );
        $self->outf( "Max Time: %.4fs\n",     $stats->{max_time} );
        $self->outf( "Slow Queries: %d (%.2f%%)\n",
          $stats->{slow_queries},
          $stats->{slow_query_percentage} );
        $self->outf( "Queries/second: %.2f\n", $stats->{queries_per_second} );

        $self->out( "\nBy Query Type:\n" );
        $self->out( "-" x 40, "\n" );

        for my $type ( sort keys %{ $stats->{query_types} } ) {
            my $type_stats = $stats->{query_types}->{$type};
            $self->out( _color('bold blue'), "\n$type:\n", _color('reset') );
            $self->outf( "  Count: %d\n",        $type_stats->{count} );
            $self->outf( "  Total Time: %.4fs\n", $type_stats->{total_time} );
            $self->outf( "  Avg Time: %.4fs\n",   $type_stats->{avg_time} );
            $self->outf( "  Min Time: %.4fs\n",   $type_stats->{min_time} );
            $self->outf( "  Max Time: %.4fs\n",   $type_stats->{max_time} );
        }
    }

    method get_slow_queries ($limit = 10) {
        my @sorted =
          sort { $b->{duration} <=> $a->{duration} } @{$queries};

        $limit = scalar @sorted if $limit > scalar @sorted;
        return [ @sorted[ 0 .. ( $limit - 1 ) ] ];
    }

    method clear () { $queries = [] }
}

1;

__END__

=head1 NAME

DBIx::Fast::Profiler - Query profiling and statistics for DBIx::Fast

=head1 DESCRIPTION

Records and analyzes SQL query execution times, providing detailed statistics,
slow query detection, and colored terminal output. Queries are stored in a
circular buffer controlled by C<max_queries>.

=head1 METHODS

=head2 add_query

    $profiler->add_query($sql, \@params, $start_time, $end_time);

Records a query execution with its SQL, bind parameters, and timing. Automatically
prints the query if C<auto_print> is enabled.

=head2 print_query

    $profiler->print_query(\%query_info);

Reports a single query. In the default C<text> output mode, prints its
details with color-coded duration (green, yellow, or red) and a stack trace
for slow queries. In C<json> or callback mode (see L<DBIx::Fast::Output>),
emits a C<query> event carrying the raw record plus a computed C<severity>
(C<ok>/C<warn>/C<slow>) instead.

=head2 print_summary

Reports a summary of query statistics grouped by query type (SELECT, INSERT,
etc.) with count, total time, average, min, and max for each type. Text mode
prints it formatted; json/callback mode emits a C<summary> event with the
L</get_detailed_stats> data.

=head2 print_stats

Reports comprehensive query statistics including totals, averages, slow query
percentage, queries per second, and a per-type breakdown. Text mode prints it
formatted; json/callback mode emits a C<stats> event with the L</get_stats>
data.

=head2 get_stats

    my $stats = $profiler->get_stats();

Returns a hashref with aggregate statistics: total_queries, total_time,
avg_time, min_time, max_time, slow_queries, slow_query_percentage,
queries_per_second, and query_types breakdown.

=head2 get_detailed_stats

    my $stats = $profiler->get_detailed_stats();

Returns a hashref keyed by query type with count, total_time, avg_time,
min_time, and max_time for each type.

=head2 get_slow_queries

    my $slow = $profiler->get_slow_queries($limit);

Returns an arrayref of the top C<$limit> (default 10) slowest queries, sorted
by duration descending.

=head2 clear

Clears all recorded queries from the profiler buffer.

=head1 SECURITY

The profiler stores B<full bind parameter values> alongside each recorded query
(see L</add_query>) and, with C<auto_print> enabled, writes them to STDOUT in
clear text. The C<json> and callback output modes (L<DBIx::Fast::Output>)
receive exactly the same cleartext values - routing to a file or logger does
not redact anything. Bind values can include passwords, payment tokens,
session identifiers, email addresses and any other PII or PCI data flowing
through the application.

B<Do not enable this profiler in production with real customer traffic.>
The intended use is development, integration tests, and ad-hoc performance
debugging against synthetic or anonymized data.

If you must run it against a live system:

=over 4

=item *

Construct it with C<< auto_print => 0 >> (the C<< $db->tracker >> accessor
already does this) so values do not hit STDOUT.

=item *

Treat the in-memory C<queries> buffer as a sensitive store. Do not log,
serialize, or expose it via debug endpoints.

=item *

Call L</clear> as soon as the analysis is complete to drop bind values from
memory.

=item *

Pair with L<DBIx::Fast> C<< errors_redact => 1 >> so driver error messages
do not echo the same values into the error buffer.

=back

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
