package DBIx::Fast::Output;

use v5.38;
use Object::Pad 0.807;
use Carp 'croak';
use Time::HiRes ();
use POSIX ();

# Unified output layer for the profiler subsystem. Every report method
# (print_stats, print_query, ...) funnels through this role instead of
# printing directly: 'text' renders exactly as before, 'json' emits one
# JSON line per event, a coderef receives the structured data.

role DBIx::Fast::Output {
    field $output    :param = 'text';   # 'text' | 'json' | CODE
    field $output_fh :param = undef;    # undef => currently selected handle

    ADJUST {
        croak "Invalid output mode: must be 'text', 'json' or a coderef"
            unless _valid_output($output);
    }

    sub _valid_output ($v) {
        return 1 if ref $v eq 'CODE';
        return 1 if !ref $v && defined $v && ($v eq 'text' || $v eq 'json');
        return 0;
    }

    # Combined get/set accessors: the generated :accessor setter cannot
    # validate, so these are written out.
    method output (@v) {
        if (@v) {
            croak "Invalid output mode: must be 'text', 'json' or a coderef"
                unless _valid_output($v[0]);
            $output = $v[0];
        }
        return $output;
    }

    method output_fh (@v) {
        $output_fh = $v[0] if @v;
        return $output_fh;
    }

    method output_is_text () {
        return !ref $output && $output eq 'text';
    }

    # The single funnel every text renderer writes through. With no explicit
    # filehandle it uses a bare print (the currently selected handle), so
    # tests and callers that localize *STDOUT keep working.
    method out (@text) {
        my $ok = $output_fh ? print {$output_fh} @text : print @text;
        warn "DBIx::Fast profiler output failed: $!\n" unless $ok;
        return $ok;
    }

    method outf ($fmt, @args) {
        return $self->out(sprintf $fmt, @args);
    }

    # Route a report event. Returns 0 in text mode (the caller renders text
    # itself via out/outf) and 1 when the event was consumed by the json or
    # callback sink. Never dies: profiling must not kill application queries.
    method emit ($event, $data) {
        return 0 if $self->output_is_text;

        my $meta = $self->_output_meta;
        $meta->{ts} = _iso_ts();

        my $ok = eval {
            if (ref $output eq 'CODE') {
                $output->($event, $data, $meta);
            }
            else {
                $self->out(_json_encoder()->encode(
                    { %$meta, event => $event, data => $data }) . "\n");
            }
            1;
        };
        warn "DBIx::Fast profiler output failed: $@" unless $ok;
        return 1;   # consumed either way - never fall through to text mid-stream
    }

    # Envelope identity, implemented by each consumer: Profile::Base tags
    # {source => 'profile', driver => <class tail>}, Profiler {source => 'tracker'}.
    method _output_meta;

    sub _json_encoder () {
        state $enc = do {
            my $class = eval { require Cpanel::JSON::XS; 'Cpanel::JSON::XS' }
                     || do { require JSON::PP; 'JSON::PP' };
            $class->new->canonical(1)->allow_blessed(1)->convert_blessed(1);
        };
        return $enc;
    }

    sub _iso_ts () {
        my $now = Time::HiRes::time();
        my $ms  = int(($now - int $now) * 1000);
        return POSIX::strftime('%Y-%m-%dT%H:%M:%S', gmtime int $now)
            . sprintf('.%03dZ', $ms);
    }
}

1;

__END__

=head1 NAME

DBIx::Fast::Output - Unified output layer for the profiler subsystem

=head1 SYNOPSIS

    # Text (default): print_* methods render to STDOUT exactly as always.
    my $db = DBIx::Fast->new(dsn => $dsn);

    # JSON lines: every print_* emits one machine-readable line instead.
    my $db = DBIx::Fast->new(dsn => $dsn, profile_output => 'json');

    # Callback: integrate with your logger, no parsing needed.
    my $db = DBIx::Fast->new(dsn => $dsn, profile_output => sub ($event, $data, $meta) {
        $log->info("profiler $meta->{source}/$event", $data);
    });

    # Switch at runtime, per object:
    $db->tracker->output('json');
    open my $fh, '>>', 'profiler.jsonl' or die $!;
    chmod 0600, 'profiler.jsonl';
    $db->profiler->output_fh($fh);

=head1 DESCRIPTION

Object::Pad role composed into L<DBIx::Fast::Profiler> and
L<DBIx::Fast::Profile::Base> (and through it every driver profile). All
C<print_*> report methods route through it: in C<text> mode they render the
same formatted output as previous releases; in C<json> or callback mode they
emit the underlying structured data (the same hashrefs the C<get_*> methods
return) and skip text rendering entirely.

=head1 EVENTS

JSON mode writes one object per line with canonical (sorted) keys:

    {"data":{...},"driver":"MariaDB","event":"stats","source":"profile","ts":"2026-08-18T12:34:56.789Z"}

C<source> is C<tracker> (the query tracker, L<DBIx::Fast::Profiler>) or
C<profile> (a driver profile; C<driver> names it). C<event> is C<query>,
C<stats> or C<summary> for the tracker, and C<stats>, C<index_analysis> or
C<connection_monitor> for profiles. C<data> is the structured report. A
callback receives the same information unencoded: C<< $cb->($event, $data,
{ts, source, driver?}) >>.

=head1 METHODS

=head2 output

    $obj->output;            # current mode
    $obj->output('json');    # set: 'text' | 'json' | coderef (validated)

=head2 output_fh

    $obj->output_fh($fh);    # where text/json lines are written

Defaults to the currently selected handle (normally STDOUT), so output
redirection done by the caller keeps working.

=head2 emit ($event, $data)

Routes one event. Returns false in text mode - the caller is expected to
render text itself through C<out>/C<outf> - and true when the json/callback
sink consumed the event. Sink failures (dying callback, closed filehandle,
unencodable data) warn and are swallowed: profiling never kills the
application's queries.

=head1 SECURITY

The json and callback sinks receive the same cleartext SQL and bind
parameters that text mode prints - including any PII the application binds.
Point them only at destinations with the same protection you would give the
database itself (note the C<chmod 0600> in the SYNOPSIS). JSON encoding
escapes control characters natively, so terminal-escape sanitization is only
applied in text mode.

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
