package DBIx::Fast::Connector;

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

class DBIx::Fast::Connector {
  field $_dsn        :param(dsn);
  field $_user       :param(user)       = '';
  field $_pass       :param(pass)       = '';
  field $_attrs      :param(attrs)      = {};
  # Coderef run with the fresh $dbh on every (re)connect. The parent DBIx::Fast
  # uses it to (re)install HandleError/trace/quote/Profile, which otherwise
  # would be lost on the new handle after a reconnect.
  field $_on_connect :param(on_connect) = undef;

  field $_dbh;
  field $_pid       = $$;
  field $_last_ping = 0;
  field $_ping_interval;
  field $_skip_ping;

  ADJUST {
    # Minimum seconds between server-side ping() calls. 1s catches server
    # restarts and wait_timeout evictions without adding a roundtrip per query.
    # Read per-instance so tests can override via local $ENV.
    $_ping_interval = $ENV{DBIX_FAST_PING_INTERVAL} // 1;
    # SQLite has no server-side connection to die, so skip ping entirely unless
    # the interval is explicitly overridden.
    $_skip_ping = !exists $ENV{DBIX_FAST_PING_INTERVAL}
      && $_dsn =~ /^dbi:SQLite\b/i;
  }

  # True when a (stale) handle had AutoCommit off, i.e. it was inside a
  # transaction. Silently reconnecting a fresh autocommit session would land the
  # rest of the transaction body on it and durably commit partial work with no
  # error, so the caller fails loudly instead. The handle MUST be disposed
  # before dying (see dbh) or the connector would stay poisoned for all calls.
  method _in_txn ($dbh) {
    my $autocommit = eval { $dbh->FETCH('AutoCommit') };
    return defined $autocommit && !$autocommit;
  }

  method dbh () {
    # Fork detection: parent handle is invalid in child
    if ($_pid != $$) {
      eval { $_dbh->STORE(InactiveDestroy => 1) } if $_dbh;
      undef $_dbh;
    }

    # Handle alive check (cheap, client-side)
    if ($_dbh && !$_dbh->FETCH('Active')) {
      my $in_txn = $self->_in_txn($_dbh);
      undef $_dbh;                       # dispose BEFORE dying
      die "DBIx::Fast::Connector: connection lost mid-transaction\n" if $in_txn;
    }

    # Server-side liveness check (throttled). DBI's Active flag does not detect
    # connections closed by the server (wait_timeout, restart, network drop)
    # until the next failing call. DBD::MariaDB 1.24 can segfault when a
    # statement is prepared on such a handle, so we verify with ping first.
    if ($_dbh && !$_skip_ping && $_ping_interval >= 0) {
      my $now = time;
      if ($now - $_last_ping >= $_ping_interval) {
        my $alive = eval { $_dbh->ping };
        if (!$alive) {
          my $in_txn = $self->_in_txn($_dbh);
          eval { $_dbh->STORE(InactiveDestroy => 1) };
          undef $_dbh;                   # dispose BEFORE dying
          die "DBIx::Fast::Connector: connection lost mid-transaction\n"
            if $in_txn;
        }
        else {
          $_last_ping = $now;
        }
      }
    }

    # (Re)connect
    unless ($_dbh) {
      # eval: with RaiseError (our default) DBI->connect croaks with a message
      # that embeds the DSN and username - and a DSN can carry a password.
      # Rethrow with the driver error only. AutoInactiveDestroy keeps a handle
      # inherited across fork() from sending a server-side disconnect when a
      # child that never touched the DB exits.
      $_dbh = eval {
        DBI->connect($_dsn, $_user, $_pass,
                     { AutoInactiveDestroy => 1, %$_attrs })
      } or die "DBIx::Fast::Connector: connect failed: "
             . ($DBI::errstr // 'unknown error') . "\n";
      $_pid       = $$;
      $_last_ping = time;
      # Reapply per-connection settings (HandleError/trace/quote/Profile) to the
      # fresh handle so they survive reconnects.
      $_on_connect->($_dbh) if $_on_connect;
    }

    return $_dbh;
  }

  method disconnect () {
    if ($_dbh) {
      if ($_pid != $$) {
        # Forked child: never send a protocol-level disconnect on the socket
        # shared with the parent - neutralize our copy and drop it.
        eval { $_dbh->STORE(InactiveDestroy => 1) };
      } else {
        $_dbh->disconnect;
      }
    }
    undef $_dbh;
  }

  method mode { }

  # --- Test seams: Object::Pad fields cannot be poked from outside, so tests
  # that simulate a dead/forked handle drive these instead. Not part of the
  # public API.
  method _test_set_handle ($h)  { $_dbh = $h;       return $self }
  method _test_set_pid    ($p)  { $_pid = $p;       return $self }
  method _test_skip_ping  ($v)  { $_skip_ping = $v; return $self }
}

1;

__END__

=head1 NAME

DBIx::Fast::Connector - DBIx::Fast connection manager fork safe

=head1 DESCRIPTION

Minimal connection manager that provides automatic reconnection and fork
safety.

=head1 METHODS

=head2 new

    my $conn = DBIx::Fast::Connector->new(
        dsn        => $dsn,
        user       => $user,
        pass       => $pass,
        attrs      => \%attrs,
        on_connect => sub ($dbh) { ... },   # optional
    );

Creates a new connector (named parameters). The DBI connection is established
lazily on the first call to C<dbh>. C<on_connect> is a coderef invoked with the
fresh handle on every (re)connect.

=head2 dbh

    my $dbh = $conn->dbh;

Returns the DBI database handle. Automatically reconnects if the handle is
stale or if the process has forked since the last connection. Dies with
"connection lost mid-transaction" if a handle is found dead while a transaction
was open (AutoCommit off), rather than silently reconnecting an autocommit
session.

=head2 disconnect

Disconnects and clears the cached handle.

=head2 mode

Accepts a mode argument (ignored). Exists for compatibility.

=head1 AUTHOR

SeHarrys

=head1 LICENSE

This is free software under the Artistic License 2.0.

=cut
