package DBIx::Fast::Profile::Pg;

use v5.38;
use Object::Pad 0.807;

class DBIx::Fast::Profile::Pg :isa(DBIx::Fast::Profile::Base) {

    method init () {
        # Nothing to enable server-side: pg_stat_* views are always available.
    }

    # Same allowlist strategy as Profile::MariaDB, tuned for PostgreSQL:
    # only DML/CTE heads, no terminators or comments, and no file/DoS
    # functions. String literals are stripped under BOTH quoting conventions
    # (standard_conforming_strings on and off) before scanning, so a crafted
    # literal cannot hide a token in either server mode.
    method _validate_explainable_sql ($sql_text) {
        return unless defined $sql_text && length $sql_text;

        $sql_text =~ s/^\s+|\s+$//g;

        return unless $sql_text =~ /^(?:SELECT|INSERT|UPDATE|DELETE|TABLE|WITH|VALUES)\b/i;

        return if $sql_text =~ m{;|--|/\*|\$\w*\$};   # terminators / comments / dollar-quoting

        my @bare;
        (my $b1 = $sql_text) =~ s/'(?:[^'\\]|\\.)*'//g;   # backslash-escape parsing
        $b1 =~ s/"(?:[^"\\]|\\.)*"//g;
        (my $b2 = $sql_text) =~ s/'[^']*'//g;             # standard_conforming_strings
        $b2 =~ s/"[^"]*"//g;

        for my $bare ($b1, $b2) {
            # File access / server-side reads and writes
            return if $bare =~ /\bpg_(?:read_file|read_binary_file|ls_dir|stat_file)\b/i;
            return if $bare =~ /\blo_(?:import|export)\b/i;
            # EXPLAIN-time DoS
            return if $bare =~ /\bpg_sleep(?:_for|_until)?\s*\(/i;
            return if $bare =~ /\bpg_advisory_lock\w*\s*\(/i;
            # Remote execution
            return if $bare =~ /\bdblink\w*\s*\(/i;
        }

        return $sql_text;
    }

    # Returns the plan as an arrayref of hashrefs with a single QUERY PLAN
    # column (PostgreSQL's native EXPLAIN output, one row per plan line).
    # Plain EXPLAIN never executes the statement.
    method explain_query ($sql, @params) {
        my $safe = $self->_validate_explainable_sql($sql);
        return unless $safe;
        return $self->dbix->all( "EXPLAIN $safe", @params );
    }

    # Key names mirror Profile::MariaDB::get_table_stats where an equivalent
    # exists (table_name/table_rows/data_length/index_length); engine is the
    # table access method (heap unless a custom AM is in use).
    method get_table_stats ($table) {
        return $self->dbix->all(q{
            SELECT c.relname                 AS table_name,
                   am.amname                 AS engine,
                   c.reltuples::bigint       AS table_rows,
                   pg_table_size(c.oid)      AS data_length,
                   pg_indexes_size(c.oid)    AS index_length
            FROM pg_class c
            JOIN pg_namespace n  ON n.oid = c.relnamespace
            LEFT JOIN pg_am am   ON am.oid = c.relam
            WHERE c.relname = ?
              AND n.nspname = current_schema()
        }, $table);
    }

    method get_index_stats ($table) {
        return $self->dbix->all(q{
            SELECT i.relname                                AS index_name,
                   a.attname                                AS column_name,
                   array_position(ix.indkey, a.attnum) + 1  AS seq_in_index,
                   s.idx_scan                               AS scans
            FROM pg_index ix
            JOIN pg_class i      ON i.oid = ix.indexrelid
            JOIN pg_class t      ON t.oid = ix.indrelid
            JOIN pg_namespace n  ON n.oid = t.relnamespace
            JOIN pg_attribute a  ON a.attrelid = t.oid
                                AND a.attnum   = ANY (ix.indkey)
            LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = ix.indexrelid
            WHERE t.relname = ?
              AND n.nspname = current_schema()
            ORDER BY i.relname, seq_in_index
        }, $table);
    }

    # Global = pg_stat_database counters for the current database.
    # Session = the current backend's row from pg_stat_activity.
    method get_stats () {
        my $global = $self->dbix->hash(q{
            SELECT xact_commit, xact_rollback,
                   blks_read, blks_hit,
                   tup_returned, tup_fetched,
                   tup_inserted, tup_updated, tup_deleted,
                   deadlocks, temp_files, temp_bytes
            FROM pg_stat_database
            WHERE datname = current_database()
        });

        my $session = $self->dbix->hash(q{
            SELECT state, backend_start, xact_start, query_start
            FROM pg_stat_activity
            WHERE pid = pg_backend_pid()
        });

        return { global => $global // {}, session => $session // {} };
    }

    method print_stats () {
        my $stats = $self->get_stats;
        # json/callback consumers also get the session block text mode omits.
        return if $self->emit( stats => $stats );

        $self->out("PostgreSQL Statistics (current database):\n");
        for my $k (sort keys %{ $stats->{global} }) {
            $self->outf("  %-14s: %s\n", $k, $stats->{global}{$k} // 0);
        }
    }

    # Same keys as Profile::MariaDB::get_current_connections.
    method get_current_connections () {
        return $self->dbix->hash(q{
            SELECT COUNT(*)                                        AS total_connections,
                   COUNT(*) FILTER (WHERE state = 'active')        AS active,
                   COUNT(*) FILTER (WHERE state LIKE 'idle%')      AS sleeping
            FROM pg_stat_activity
            WHERE datname = current_database()
        });
    }

    method get_processes () {
        return $self->dbix->all(q{
            SELECT pid                                          AS id,
                   usename                                      AS "user",
                   COALESCE(client_addr::text, 'local')         AS host,
                   datname                                      AS db,
                   state,
                   EXTRACT(EPOCH FROM (now() - query_start))::int AS seconds_running,
                   query
            FROM pg_stat_activity
            WHERE datname = current_database()
            ORDER BY query_start
        });
    }

    # Requires the pg_stat_statements extension; returns an empty arrayref
    # when it is not installed instead of dying.
    method get_slow_queries () {
        my $rows = eval {
            $self->dbix->all(q{
                SELECT query                    AS sql_text,
                       calls                    AS stages,
                       total_exec_time / 1000   AS total_time
                FROM pg_stat_statements
                WHERE total_exec_time / 1000 > ?
                ORDER BY total_exec_time DESC
                LIMIT 20
            }, $self->slow_query);
        };
        return $rows // [];
    }
}

1;

__END__

=head1 NAME

DBIx::Fast::Profile::Pg - PostgreSQL profiling and diagnostics

=head1 SYNOPSIS

    my $db = DBIx::Fast->new( dsn => 'postgres://user:pass@host/db' );

    my $plan  = $db->profiler->explain_query('SELECT * FROM t WHERE id = ?', 1);
    my $ts    = $db->profiler->get_table_stats('t');
    my $is    = $db->profiler->get_index_stats('t');
    my $stats = $db->profiler->get_stats;
    my $conns = $db->profiler->get_current_connections;

=head1 DESCRIPTION

PostgreSQL implementation of L<DBIx::Fast::Profile::Base>, built on the
C<pg_stat_*> statistics views and C<pg_class>/C<pg_index> catalogs. Where a
MariaDB equivalent exists, result keys mirror
L<DBIx::Fast::Profile::MariaDB> so polymorphic callers work across drivers.

=head1 METHODS

=head2 explain_query ($sql, @params)

Runs C<EXPLAIN> on the statement after validating it against the same
allowlist strategy used by the MariaDB profile (DML/CTE heads only; no
terminators, comments or dollar-quoting; rejects C<pg_read_file>-style file
access, C<pg_sleep>/advisory-lock DoS and C<dblink> under both string
quoting modes). Returns the plan rows or undef when validation fails.
Plain C<EXPLAIN> never executes the statement.

=head2 get_table_stats ($table)

Row count estimate and on-disk sizes for a table in the current schema.

=head2 get_index_stats ($table)

Indexes with their columns, position and scan counts.

=head2 get_stats / print_stats

C<pg_stat_database> counters for the current database plus the current
backend's C<pg_stat_activity> row.

=head2 get_current_connections / get_processes

Connection summary (total/active/sleeping) and per-backend process list for
the current database.

=head2 get_slow_queries

Top statements by total execution time from C<pg_stat_statements>; returns
an empty arrayref when the extension is not installed.

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
