package DBIx::Class::Async::Row;

use strict;
use warnings;
use version;

our $VERSION   = qv('v1.0.6');
our $AUTHORITY = 'cpan:MANWAR';

=head1 NAME

DBIx::Class::Async::Row - Asynchronous Row object representing a single database record.

=head1 VERSION

Version v1.0.6

=head1 SYNOPSIS

    # Rows are typically retrieved via a ResultSet
    my $future = $rs->find(1);

    $future->on_done(sub {
        my $user = shift;

        # Access columns (non-blocking if pre-inflated)
        say $user->username;

        # Update columns
        $user->last_login('2026-02-02');

        # Persist changes asynchronously
        $user->update->on_done(sub {
            say "User updated!";
        });

        # Traverse relationships (Returns a Future)
        $user->posts->all->on_done(sub {
            my $posts = shift;
            say "User has " . scalar(@$posts) . " posts.";
        });
    });

=head1 DESCRIPTION

This class provides an asynchronous interface to individual database rows.
It maintains internal state regarding "dirty" (unsaved) columns and handles
the hand-off to the background worker for all I/O operations.

=head1 ATTRIBUTES

=head2 in_storage

    my $bool = $row->in_storage;
    $row->in_storage(1);

Returns or sets whether the row is considered "in the database." Setting
this to true clears the "dirty" status of all columns.

=head1 PERSISTENCE METHODS

=head2 update

    $row->update({ status => 'active' })->then(sub { ... });

If the row is in storage, dispatches an C<UPDATE> command to the worker for any
modified (dirty) columns. Returns a L<Future> resolving to the updated row object.

=head2 delete

    $row->delete->on_done(sub { say "Gone!" });

Asynchronously deletes the row from the database using its Primary Key.
Upon success, C<in_storage> is set to 0.

=head2 discard_changes

    $row->discard_changes->then(sub { ... });

Re-fetches the row data from the database, overwriting any local unsaved changes
and resetting the "dirty" flag.

=head2 copy

    $row->copy({ username => 'new_user' })->then(sub { ... });

Creates a new record in the database using the current row's data as a template,
excluding Primary Keys unless explicitly provided in the changes hashref.

=head2 insert_query

    my ($sql, @bind) = $row->insert_query;
    my $query = $row->insert_query;

Returns the SQL and bind values that would be generated by an insert operation
without actually executing it. The row must be in_storage(false) state.

    my $row = $schema->resultset('User')->new({
        name  => 'Alice',
        email => 'alice@example.com',
    });

    my ($sql, @bind) = $row->insert_query;
    # $sql = \'INSERT INTO users (name, email) VALUES (?, ?)'
    # @bind = ('Alice', 'alice@example.com')

    # Now actually insert
    $row->insert->get;

=cut

=head1 DATA ACCESSORS

=head2 get_column / set_column

    my $val = $row->get_column('email');
    $row->set_column('email', 'new@example.com');

Standard DBIC-style column accessors. C<set_column> marks the column as "dirty."

=head2 get_dirty_columns

    my %dirty = $row->get_dirty_columns;

Returns a list of key-value pairs for columns that have been modified but
not yet saved to the database.

=head1 RELATIONSHIPS

=head2 related_resultset

    my $rel_rs = $row->related_resultset('posts');

Returns a L<DBIx::Class::Async::ResultSet> for the named relationship, pre-configured
with the foreign key join condition.

=head2 create_related

    $row->create_related('posts', { title => 'New Post' });

Shorthand for C<< $row->related_resultset($rel)->create($data) >>.

=cut

use utf8;
use v5.14;

use Carp;
use Future;
use Scalar::Util qw(blessed);

# PRIVATE CONSTANTS
# Protects internal attributes from being treated as database columns
my $INTERNAL_KEYS = qr/^(?:_.*|async_db|source_name|schema|_inflation_map)$/;

sub new {
    my ($class, %args) = @_;

    croak "Missing required argument: schema_instance" unless $args{schema_instance};
    croak "Missing required argument: async_db"        unless $args{async_db};
    croak "Missing required argument: source_name"     unless $args{source_name};
    croak "Missing required argument: row_data"        unless $args{row_data};

    my $in_storage = delete $args{in_storage} // 0;
    my $data       = $args{row_data} || $args{_data} || {};

    # Capture pre-inflated relationship objects
    my $rel_data   = $args{relationship_data} // {};

    my $self = bless {
        _schema_instance => $args{schema_instance},
        _async_db        => $args{async_db},
        _source_name     => $args{source_name},
        _result_source   => $args{result_source} // undef,
        _data            => { %$data },
        _dirty           => {},
        _inflated        => {},
        _related         => { %$rel_data },
        _in_storage      => $in_storage,
        _inflation_map   => {},
    }, $class;

    $self->_ensure_accessors;

    # WARM-UP: Pre-calculate metadata and shadow plain columns for speed
    my $source = $self->_get_source;
    if ($source && ref($source) && eval { $source->can('has_column') }) {

        # 1. Shadow physical columns
        foreach my $col (keys %$data) {
            # Skip internal plumbing
            next if $self->_is_internal($col);

            if ($source->has_column($col)) {
                my $info = $source->column_info($col);
                my $inflator = $info->{inflate} // 0;
                $self->{_inflation_map}{$col} = $inflator;

                # If NO inflator, shadow to top-level for direct hash-key speed
                if (!$inflator) {
                    $self->{$col} = $data->{$col};
                }
            }
        }

        # 2. Shadow relationship objects for fast access
        foreach my $rel (keys %$rel_data) {
            $self->{$rel} = $rel_data->{$rel};
            # Mark as "inflated" so accessors don't try to re-fetch from DB
            $self->{_inflation_map}{$rel} = 0;
        }
    }

    return $self;
}

sub copy {
    my ($self, $changes) = @_;
    $changes //= {};

    my $source = $self->_get_source;

    # 1. Get ONLY keys that are valid database columns
    my %data;
    foreach my $col ($source->columns) {
        # Check _data first (clean storage), then top-level (hack storage)
        if (exists $self->{_data}{$col}) {
            $data{$col} = $self->{_data}{$col};
        }
        elsif (exists $self->{$col}) {
            # Ensure we don't accidentally copy our own management objects
            # if a column happened to have the same name
            next if $col =~ /^(?:async_db|schema|source_name|_source)$/;
            $data{$col} = $self->{$col};
        }
    }

    # 2. Remove Primary Keys (unless specifically overridden in $changes)
    foreach my $pk ($source->primary_columns) {
        delete $data{$pk} unless exists $changes->{$pk};
    }

    # 3. Apply user changes
    foreach my $col (keys %$changes) {
        $data{$col} = $changes->{$col};
    }

    # 4. Use the schema to create the copy
    my $schema = $self->{_schema_instance};

    unless ($schema) {
        # If no schema instance, we need to get it from somewhere
        # This shouldn't happen if Row was created properly
        croak "Cannot copy: Row object has no schema instance reference";
    }

    return $schema->resultset($self->{_source_name})->create(\%data);
}

sub create_related {
    my ($self, $rel_name, $col_data) = @_;

    my $rs = $self->related_resultset($rel_name);

    my $rs_cond = (ref $rs->{cond} eq 'HASH')  ? $rs->{cond}
                : (ref $rs->{_cond} eq 'HASH') ? $rs->{_cond}
                : {};

    my $merged_data = { %$rs_cond, %{$col_data || {}} };

    return $rs->create($merged_data);
}

sub delete {
    my ($self) = @_;

    unless ($self->in_storage) {
        # Idempotent - already deleted, just return 0
        return Future->done(0);
    }

    my $db          = $self->{_async_db};
    my $source_name = $self->{_source_name};
    my $source      = $self->result_source;
    my ($pk_col)    = $source->primary_columns;

    my $id_val = $self->{_data}{$pk_col}
              // ($self->can($pk_col) ? $self->$pk_col : undef)
              // $self->{$pk_col};

    if (!defined $id_val) {
        return Future->fail("Cannot delete: Primary key ($pk_col) is missing");
    }

    return DBIx::Class::Async::_call_worker(
        $db,
        'delete',
        {
            source_name => $source_name,
            cond        => { $pk_col => $id_val }
        }
    )->then(sub {
        my $result = shift;

        # Mark as not in storage
        $self->in_storage(0);

        return Future->done($result);
    });
}

sub discard_changes {
    my $self = shift;

    # 1. Primary Key Validation (Matching your old design)
    my $source = $self->{_schema_instance}->resultset($self->{_source_name})->result_source;
    my @pk = $source->primary_columns;

    croak("Cannot discard changes on row without primary key") unless @pk;
    croak("Composite primary keys not yet supported") if @pk > 1;

    my $pk_col = $pk[0];
    my $id = $self->{_data}{$pk_col}; # Using new internal _data structure

    croak("Cannot discard changes: primary key value is undefined")
        unless defined $id;

    # 2. Fetch fresh data using the new re-anchored design
    # We use find($id) which returns a Future containing a new Row object
    return $self->_spawn_rs->search({ $pk_col => $id })->first->then(sub {
        my ($fresh_row) = @_;

        # In the new design, find() returns undef or a Row object
        unless ($fresh_row) {
            return Future->fail("Row vanished from database", 'db_error');
        }

        # 3. Synchronise internal state
        # We extract the columns from the fresh object into this one
        my $raw_data = $fresh_row->{_data};

        $self->{_data}  = { %$raw_data };
        $self->{_dirty} = {};

        # 4. Refresh speed shadows (Warm-up optimisation)
        $self->_ensure_accessors;

        return Future->done($self);
    });
}

sub get_column {
    my ($self, $col) = @_;

    # 1. Fast-track internal plumbing
    return $self->{$col} if $self->_is_internal($col);

    # 2. Return cached inflated value if it exists
    return $self->{_inflated}{$col} if exists $self->{_inflated}{$col};

    # 3. Discovery & Exception Handling
    if (!exists $self->{_inflation_map}{$col}) {
        my $source = $self->_get_source;

        # If it's not in data AND not in schema, trigger DBIC exception
        if ($source && !$source->has_column($col) && !exists $self->{_data}{$col}) {
            # Calling column_info on a non-existent column triggers the "No such column" croak
            return $source->column_info($col);
        }

        if ($source && $source->has_column($col)) {
            $self->{_inflation_map}{$col} = $source->column_info($col)->{inflate} // 0;
        } else {
            $self->{_inflation_map}{$col} = 0;
        }
    }

    my $raw      = $self->{_data}{$col};
    my $inflator = $self->{_inflation_map}{$col};

    # 4. Inflate if needed
    if ($inflator && defined $raw) {
        # Check if already inflated via shadow key
        if ("$raw" =~ /^HASH\(0x/ && ref $self->{$col}) {
            return $self->{$col};
        }

        my $inflated = eval { $inflator->("$raw", $self) };
        if (!$@ && defined $inflated) {
            $self->{_inflated}{$col} = $inflated;
            $self->{$col} = $inflated;
            return $inflated;
        }
    }

    return $raw;
}

sub get_columns {
    my $self = shift;
    my $data = $self->{_data} || {};

    # 1. Get the list of valid column names from the source
    my @valid_cols = $self->_get_source->columns;

    # 2. Only extract keys that are actual database columns
    my %cols;
    foreach my $col (@valid_cols) {
        if (exists $data->{$col}) {
            $cols{$col} = $data->{$col};
        }
    }

    return wantarray ? %cols : \%cols;
}

sub get_dirty_columns {
    my $self = shift;

    my %dirty_values;
    foreach my $column (keys %{$self->{_dirty}}) {
        $dirty_values{$column} = $self->{_data}{$column};
    }

    return wantarray ? %dirty_values : \%dirty_values;
}

sub get_inflated_columns {
    my $self = shift;

    my %inflated;
    foreach my $col (keys %{$self->{_data}}) {
        $inflated{$col} = $self->get_column($col);
    }

    return %inflated;
}

sub id {
    my $self = shift;

    croak("id() cannot be called as a class method")
        unless ref $self;

    my $source = $self->_get_source;
    unless ($source && ref($source)
        && eval { $source->can('primary_columns') }) {
        croak("Could not retrieve valid ResultSource for " .
              ($self->{_source_name} // 'Unknown') .
              ". Source is either missing or unblessed.");
    }

    my @pk_columns = $source->primary_columns;

    croak("No primary key defined for " . $self->{source_name})
        unless @pk_columns;

    my @pk_values;
    foreach my $col (@pk_columns) {
        my $val = $self->get_column($col);

        # Warn if primary key is undefined (usually means row not in storage)
        unless (defined $val) {
            carp("Primary key column '$col' is undefined for " .
                 $self->{source_name});
        }

        push @pk_values, $val;
    }

    # Return based on context
    if (wantarray) {
        # List context: return list
        return @pk_values;
    } else {
        # Scalar context
        if (@pk_values == 1) {
            # Single primary key: return the value
            return $pk_values[0];
        } else {
            # Composite primary key: return arrayref
            return \@pk_values;
        }
    }
}

sub ident_condition {
    my $self = shift;
    my $source = $self->{_result_source}
               || $self->{_schema_instance}->resultset($self->{_source_name})->result_source;

    my @pks = $source->primary_columns;
    croak "ident_condition failed: No primary keys defined for " . $self->{_source_name} unless @pks;

    # Return a HASHREF instead of a list to make it easier to use elsewhere
    return { map { $_ => $self->{_data}{$_} } @pks };
}

sub insert {
    my $self = shift;

    # If the row is already in the database, DBIC behavior is to throw an error
    # or no-op. Here we follow the safer path.
    if ($self->in_storage) {
        return Future->fail("Check failed: count of objects to be inserted is 0 (already in storage)");
    }

    # update_or_insert handles the actual DB communication and state flipping
    return $self->update_or_insert;
}

sub insert_or_update { shift->update_or_insert(@_); }

sub in_storage {
    my ($self, $val) = @_;

    if (defined $val) {
        $self->{_in_storage} = $val ? 1 : 0;
        # If it's in storage, it's no longer 'dirty' (unsaved changes)
        $self->{_dirty} = {} if $self->{_in_storage};
    }

    return $self->{_in_storage} // 0;
}

sub insert_query {
    my $self = shift;

    Carp::croak("insert_query can only be called on rows not yet in storage")
        if $self->in_storage;

    my $bridge       = $self->{_async_db};
    my $schema_class = $bridge->{_schema_class};

    unless ($schema_class->can('resultset')) {
        eval "require $schema_class" or die "insert_query: $@";
    }

    # Silence warnings
    local $SIG{__WARN__} = sub {
        warn @_ unless $_[0] =~ /undetermined_driver|sql_limit_dialect|GenericSubQ/;
    };

    unless ($bridge->{_metadata_schema}) {
        $bridge->{_metadata_schema} = $schema_class->connect('dbi:NullP:');
    }

    my $storage   = $bridge->{_metadata_schema}->storage;
    my $source    = $bridge->{_metadata_schema}->source($self->{_source_name});
    my %to_insert = $self->get_columns;

    my ($sql, @bind);

    eval {
        my $table = $source->from;
        my @cols  = sort keys %to_insert;
        my @placeholders = ('?') x @cols;

        my $cols_sql = join(', ', @cols);
        my $vals_sql = join(', ', @placeholders);

        $sql  = \"INSERT INTO $table ($cols_sql) VALUES ($vals_sql)";
        @bind = @to_insert{@cols};
    };

    if ($@) {
        croak("Failed to generate insert SQL: $@");
    }

    return wantarray ? ($sql, @bind) : \[ $sql, @bind ];
}

sub is_column_changed {
    my ($self, $column) = @_;

    croak("column name required") unless defined $column;

    return exists $self->{_dirty}{$column} ? 1 : 0;
}

sub is_column_dirty {
    my ($self, $column) = @_;
    return exists $self->{_dirty}{$column} ? 1 : 0;
}

sub make_column_dirty {
    my ($self, $column) = @_;

    croak("column name required") unless defined $column;

    $self->{_dirty}{$column} = 1;

    return $self;
}

sub related_resultset {
    my ($self, $rel_name, $cond, $attrs) = @_;

    # 1. Get metadata from the schema class (no temp_schema needed)
    my $source_obj = $self->{_async_db}->{_schema_class}->source($self->{_source_name});
    my $rel_info   = $source_obj->relationship_info($rel_name);
    die "No such relationship '$rel_name' on " . $self->{_source_name} unless $rel_info;

    # 2. Build the Join Condition (the foreign keys)
    my $join_cond = {};
    if ($rel_info->{cond}) {
        while (my ($foreign_col, $self_col) = each %{$rel_info->{cond}}) {
            $foreign_col =~ s/^foreign\.//;
            $self_col =~ s/^self\.//;
            $join_cond->{$foreign_col} = $self->get_column($self_col);
        }
    }

    # 3. Finalise condition and source name
    my $final_cond = { %$join_cond, %{ $cond || {} } };

    # Carry forward the relationship where condition.
    # rel_info->{attrs}{where} was previously silently discarded here,
    # meaning datetime filters (or any where on the relationship definition)
    # were never applied when traversing via the row accessor.
    my $final_attrs = { %{ $attrs || {} } };
    if (my $rel_where = $rel_info->{attrs}{where}) {
        if (exists $final_attrs->{where}) {
            $final_attrs->{where} = { -and => [ $final_attrs->{where}, $rel_where ] };
        }
        else {
            $final_attrs->{where} = $rel_where;
        }
    }

    my $foreign_source_name = $rel_info->{source};
    my $target_result_class = $rel_info->{class} || $foreign_source_name;

    # 4. Path A: Prefetched Data Cache
    if (exists $self->{_relationship_data}{$rel_name}) {
        my $prefetched = $self->{_relationship_data}{$rel_name};

        my $rs = DBIx::Class::Async::ResultSet->new(
             schema_instance => $self->{_schema_instance},
             async_db        => $self->{_async_db},
             source_name     => $foreign_source_name,
             cond            => $final_cond,
             result_class    => $target_result_class,
             attrs           => $final_attrs,
        );

        $rs->{_is_prefetched} = 1;
        $rs->{_entries}       = ref $prefetched eq 'ARRAY' ? $prefetched : [$prefetched];
        return $rs;
    }

    # 5. Path B: Standard Async Database Path
    return DBIx::Class::Async::ResultSet->new(
         schema_instance => $self->{_schema_instance},
         async_db        => $self->{_async_db},
         source_name     => $foreign_source_name,
         result_class    => $target_result_class,
         cond            => $final_cond,
         attrs           => $final_attrs,
    );
}

sub result_source {
    my $self = shift;
    return $self->_get_source;
}

sub set_column {
    my ($self, $col, $value) = @_;

    if (!defined $col || $col eq '') {
         require Carp;
         Carp::croak("Column name required for set_column");
    }

    # If someone tries to set 'async_db' or '_source', we return early
    # to protect the object's plumbing and prevent "dirty" poisoning.
    return $value if $self->_is_internal($col);

    # 1. Capture types before any operations occur
    my $old = $self->{_data}{$col};
    my $old_ref = ref($old)   || 'SCALAR';
    my $new_ref = ref($value) || 'SCALAR';

    my $changed = 0;
    if (!defined $old && defined $value) {
        $changed = 1;
    } elsif (defined $old && !defined $value) {
        $changed = 1;
    } elsif (defined $old && defined $value) {
        if ($old_ref ne 'SCALAR' || $new_ref ne 'SCALAR') {
            $changed = 1;
        } else {
            # Safe to use string comparison
            if ($old ne $value) {
                $changed = 1;
            }
        }
    }

    if ($changed) {
        $self->{_data}{$col} = $value;
        $self->{_dirty}{$col} = 1;

        # Clear the inflated cache so the next 'get_column' re-inflates the new data
        delete $self->{_inflated}{$col};

        # We delete the top-level key so it doesn't "shadow" the new data
        delete $self->{$col};
    }

    return $value;
}

sub set_columns {
    my ($self, $values) = @_;

    croak("hashref of column-value pairs required")
        unless defined $values && ref $values eq 'HASH';

    while (my ($column, $value) = each %$values) {
        $self->set_column($column, $value);
    }

    return $self;
}

sub _update_internal_state {
    my ($self, $res) = @_;

    # 1. Update the internal data hash directly
    # This assumes $res contains the updated column data
    if (ref $res eq 'HASH') {
        foreach my $col (keys %$res) {
            $self->{_data}{$col} = $res->{$col};
            # Optionally update the shadow variable if necessary
            $self->{$col} = $res->{$col} if exists $self->{$col};
        }
    }

    # 2. Update state flags
    $self->{_in_storage} = 1;
    delete $self->{_dirty}; # Clear dirty flags

    return $self;
}

sub update {
    my ($self, $values) = @_;

    if (!$self->{_schema_instance}) {
        # Return a failed future to prevent further cascading failures
        return Future->fail("ResultSet is not initialized with a schema instance.");
    }

    unless ($self->in_storage) {
        return Future->fail("Cannot update row: not in storage. Did you mean to call insert or update_or_insert?");
    }

    if ($values) {
        croak("Usage: update({ col => val })") unless ref $values eq 'HASH';
        foreach my $col (keys %$values) {
            $self->set_column($col, $values->{$col});
        }
    }

    return $self->update_or_insert->on_done(sub {
        # 1. Get the source and necessary metadata
        my $source = $self->result_source;
        my $schema = $source->schema;
        my $source_name = $source->source_name;

        # 2. Explicitly create the custom ResultSet object using your new() method
        my $rs = DBIx::Class::Async::ResultSet->new(
            schema_instance => $schema,
            source_name     => $source_name,
            async_db        => $self->{_async_db},
        );

        # 3. Construct the PK condition
        my @pk_cols = $source->primary_columns;
        my %pk_cond = map { $_ => $self->get_column($_) } @pk_cols;

        # 4. Now we can safely call _generate_cache_key
        my $cache_key = $rs->_generate_cache_key(0, \%pk_cond);

        $rs->clear_cache($cache_key);
        return $self->_update_internal_state($rs);
    });
}

sub update_or_insert {
    my ($self, $data) = @_;

    my $async_db    = $self->{_async_db};
    my $source_name = $self->{_source_name};
    my $source      = $self->result_source;
    my ($pk_col)    = $source->primary_columns;

    # 1. Apply changes to the object
    if ($data && ref $data eq 'HASH') {
        foreach my $col (keys %$data) {
            $self->set_column($col, $data->{$col});
        }
    }

    my $is_update = $self->in_storage;

    # 2. Prepare Payload
    my %raw_payload = $is_update ? $self->get_dirty_columns : %{ $self->{_data} // {} };
    my %to_save;

    foreach my $col (keys %raw_payload) {
        # If it's not in _inflated, fall back to the raw value in %raw_payload.
        my $val  = exists $self->{_inflated}{$col} ? $self->{_inflated}{$col} : $raw_payload{$col};
        my $info = $source->column_info($col);

        # If a deflate handler exists and we have a reference, turn it into a string
        if ($info && $info->{deflate} && defined $val && ref $val) {
            $val = $info->{deflate}->($val, $self);
        }

        $to_save{$col} = $val;
    }

    # 3. Success handler
    my $on_success = sub {
        my ($res) = @_;

        # We check if it's an object FIRST before asking what kind of object it is.
        if (blessed($res) && $res->isa('DBIx::Class::Exception')) {
             return Future->fail($res->msg, 'db_error');
        }

        # Also check for the HASH-style error envelope which we saw in your logs
        if (ref $res eq 'HASH' && ($res->{error} || $res->{__error})) {
             my $err = $res->{error} // $res->{__error};
            return Future->fail($err, 'db_error');
        }

        # Normalise data source
        my $final_data;
        if (ref $res && ref $res eq 'HASH') {
            $final_data = $res;
        }
        elsif (ref $res && eval { $res->can('get_columns') }) {
            # Handle case where $res is another Row object
            my %cols = $res->get_columns;
            $final_data = \%cols;
        }
        else {
            # Scalar result (ID) or fallback
            $final_data = { %to_save };
            if (!$is_update && defined $res && !ref $res && $pk_col) {
                $final_data->{$pk_col} = $res;
            }
        }

        $self->{_in_storage} = 1;
        $self->in_storage(1);

        foreach my $col (keys %$final_data) {
            my $new_val = $final_data->{$col};

            # 1. Update core data first
            $self->{_data}{$col} = $new_val;

            # 1. Clear caches
            delete $self->{_inflated}{$col};
            delete $self->{_dirty}{$col};

            # 3. Handle the Shadow Key with a "Column Only" safety check
            my $source = $self->can('_get_source') ? $self->_get_source : undef;

            # CRITICAL: Only call column_info IF the source confirms it is a real column.
            # This skips 'schema', '_source', 'async_db', and relationships.
            if ($source && $source->has_column($col)) {
                my $info = $source->column_info($col);
                if ($info && $info->{inflate}) {
                    # For inflated cols, delete shadow to force get_column() to run.
                    delete $self->{$col};
                }
                else {
                    # For standard columns, update the shadow.
                    $self->{$col} = $new_val;
                }
            }
            else {
                # If it's an internal attribute or relationship,
                # just update the shadow if it already exists,
                # but NEVER ask column_info about it.
                $self->{$col} = $new_val if exists $self->{$col};
            }
        }

        return $self;
    };

    # 4. Dispatch
    if ($is_update) {
        return Future->done($self) unless keys %to_save;

        my $id_val = $self->{_data}{$pk_col}
            // ( $self->can($pk_col) ? $self->$pk_col : undef )
            // $self->{$pk_col};

        if (ref $id_val && eval { $id_val->can('get_column') }) {
            $id_val = $id_val->get_column($pk_col);
        }

        if (!defined $id_val) {
            return Future->fail(
                "Cannot update row: Primary key ($pk_col) is missing",
                "logic_error");
        }

        return DBIx::Class::Async::_call_worker(
            $self->{_async_db},
            'update',
            {
                source_name => $source_name,
                cond        => { $pk_col => $id_val },
                updates     => \%to_save
            })->then(sub {
                my $res = shift;
                # Handle worker exceptions
                if (blessed($res) && $res->isa('DBIx::Class::Exception')) {
                    return Future->fail("$res", 'db_error');
                }
                return $on_success->($res);
            });
    } else {
        return DBIx::Class::Async::_call_worker(
            $self->{_async_db},
            'create',
            {
                source_name => $source_name,
                data        => \%to_save
            }
            )->then(sub {
                my $res = shift;

                # 1. Catch Exception objects from the Worker
                if (blessed($res) && $res->isa('DBIx::Class::Exception')) {
                    return Future->fail("$res", 'db_error');
                }

                # 2. Hand off to your excellent on_success handler
                # This will set in_storage(1) and clear dirty flags.
                return $on_success->($res);
            });
    }
}

sub AUTOLOAD {
    my $self = shift;

    our $AUTOLOAD;
    my ($method) = $AUTOLOAD =~ /([^:]+)$/;

    # 1. Immediate exit for core/Future/Perl methods
    return if $method =~ /^(?:DESTROY|AWAIT_\w+|can|isa|then|get|on_\w+|failure|else|CLONE)$/;

    # 2. Handle Columns (Getter/Setter)
    # Ensure _get_source retrieves the metadata from the parent's schema cache
    my $source = eval { $self->_get_source };

    if ($source && $source->has_column($method)) {
        no strict 'refs';
        no warnings 'redefine';

        my $accessor = sub {
            my ($inner_self, $new_val) = @_;
            my $col_info = $inner_self->result_source->column_info($method);

            # SETTER MODE
            if (@_ > 1) {
                delete $inner_self->{_inflated}{$method};
                my $to_store = $new_val;

                if ($col_info->{deflate} && defined $new_val) {
                    $to_store = $col_info->{deflate}->($new_val, $inner_self);
                }

                $inner_self->set_column($method, $to_store);

                if ($col_info->{inflate} && ref $new_val) {
                    $inner_self->{_inflated}{$method} = $new_val;
                }
                return $new_val;
            }

            # GETTER MODE
            return $inner_self->{_inflated}{$method}
                if exists $inner_self->{_inflated}{$method};

            my $raw = $inner_self->get_column($method);
            if ($col_info->{inflate} && defined $raw) {
                my $inflated = $col_info->{inflate}->($raw, $inner_self);

                # Handle potential double-inflation edge cases
                if (!ref($inflated)
                    && defined($inflated)
                    && $inflated =~ /^mailto:mailto:/) {
                    $inner_self->{_inflated}{$method} = $raw;
                    return $raw;
                }

                $inner_self->{_inflated}{$method} = $inflated;
                return $inflated;
            }
            return $raw;
        };

        # Install into the specific class (likely the Anon hybrid class)
        my $target_class = ref($self);
        *{"${target_class}::$method"} = $accessor;
        return $self->$method(@_);
    }

    # 3. Handle Relationships
    if ($source && $source->can('relationship_info')) {
        my $rel_info = $source->relationship_info($method);
        if ($rel_info) {
            no strict 'refs';
            no warnings 'redefine';
            my $class = ref($self);
            *{"${class}::$method"} = sub {
                my ($current_row) = @_;

                my $row_id = eval {
                    $current_row->get_column('id')
                } // 'unknown';

                # Check if the relationship data exists
                if (exists $current_row->{_relationship_data}{$method}) {
                    my $prefetched = $current_row->{_relationship_data}{$method};

                    if (($rel_info->{attrs}{accessor} // '') ne 'multi') {
                        if (Scalar::Util::blessed($prefetched)) {
                            return Future->done($prefetched);
                        }
                        if (ref($prefetched) eq 'HASH') {
                            my $rs  = $current_row->{_schema_instance}->resultset($rel_info->{source});
                            my $obj = $rs->new_result($prefetched);
                            $current_row->{_relationship_data}{$method} = $obj;
                            return Future->done($obj);
                        }
                        return Future->done($prefetched);
                    }
                    return $prefetched;
                }

                return $current_row->related_resultset($method);
            };

            return $self->$method(@_);
        }
    }

    # 4. Fallback for non-column data already in the buffer
    if (exists $self->{_data}{$method} && !@_) {
        return $self->{_data}{$method};
    }

    # THE HIJACK GUARD
    # ----------------------------------------------------------------------
    # If we reached here, Async::Row doesn't recognise this as a DB column.
    # We check if a custom class further up the @ISA chain has this method.

    # 'can' looks through the entire inheritance tree (MRO)
    my $next_method = $self->can($method);

    if ($next_method && $next_method != \&AUTOLOAD) {
        # We found the real method (e.g., My::Custom::User::hello_name)!
        # Use goto to jump into it, preserving the caller context.
        unshift @_, $self;
        goto &$next_method;
    }

    # 5. Exception handling
    croak(sprintf(
        "Method '%s' not found in package '%s'. " .
        "(Can't locate object method via AUTOLOAD. " .
        "Is it a missing column or relationship in your ResultSource?)",
        $method, ref($self)
    ));
}

=head1 DESTROY

    # Called automatically when object is destroyed

Destructor method.

=cut

sub DESTROY {
    # Nothing to do
}

=head1 INTERNAL METHODS

These methods are for internal use and are documented for completeness.

=cut

sub _spawn_rs {
    my ($self) = @_;

    die "Cannot spawn ResultSet: missing _schema_instance or _source_name"
        unless $self->{_schema_instance} && $self->{_source_name};

    return DBIx::Class::Async::ResultSet->new(
        schema_instance => $self->{_schema_instance}, # Match ResultSet's new()
        async_db        => $self->{_async_db},
        source_name     => $self->{_source_name},
        cond            => $self->ident_condition,
    );
}

sub _mark_clean {
    my $self = shift;
    $self->{_dirty} = {};
    return $self;
}

=head2 _build_relationship_accessor

    my $coderef = $row->_build_relationship_accessor($method, $rel_info);

Builds an accessor for a relationship that checks for prefetched data first,
then falls back to lazy loading if needed. For has_many relationships, the
ResultSet object is cached in the row.

=cut

sub _build_relationship_accessor {
    my ($self, $rel_name, $rel_info) = @_;

    my $rel_type = $rel_info->{attrs}{accessor} || 'single';
    my $cond = $rel_info->{cond};

    if ($rel_type eq 'single' || $rel_type eq 'filter') {
        # belongs_to or might_have relationship
        return sub {
            my $row = shift;

            # 1. CHECK FOR PREFETCHED DATA FIRST
            if (exists $row->{_prefetched} && exists $row->{_prefetched}{$rel_name}) {
                my $prefetched = $row->{_prefetched}{$rel_name};
                return Future->done($prefetched) if blessed($prefetched);
                return Future->done(undef);
            }

            # 2. LAZY LOAD: Extract foreign key from condition
            my $fk = $row->_extract_foreign_key($cond);
            return Future->done(undef) unless $fk;

            my $fk_value = $row->get_column($fk->{self});
            return Future->done(undef) unless defined $fk_value;

            # 3. Fetch related row asynchronously via schema->resultset
            my $rel_source = $row->_get_source->related_source($rel_name);
            my $rel_rs = $row->{schema}->resultset($rel_source->source_name);

            return $rel_rs->find({ $fk->{foreign} => $fk_value });
        };

    }
    elsif ($rel_type eq 'multi') {
        # has_many relationship
        return sub {
            my $row = shift;
            my $extra_cond = shift || {};

            # Cache key for this relationship (includes extra conditions)
            my $cache_key = $rel_name;
            if (%$extra_cond) {
                # If there are extra conditions, create a unique cache key
                require Data::Dumper;
                local $Data::Dumper::Sortkeys = 1;
                local $Data::Dumper::Terse = 1;
                $cache_key .= '_' . Data::Dumper::Dumper($extra_cond);
            }

            # 1. CHECK FOR CACHED RESULTSET (without extra conditions)
            # Return cached ResultSet if it exists and no extra conditions were provided
            if (!%$extra_cond && exists $row->{_relationship_cache} && exists $row->{_relationship_cache}{$rel_name}) {
                return $row->{_relationship_cache}{$rel_name};
            }

            # 2. CHECK FOR PREFETCHED DATA
            if (exists $row->{_prefetched} && exists $row->{_prefetched}{$rel_name}) {
                my $prefetched_rs = $row->{_prefetched}{$rel_name};

                # If extra conditions are provided, filter the prefetched data
                if (%$extra_cond) {
                    # Don't cache filtered ResultSets
                    return $prefetched_rs->search($extra_cond);
                }

                # Cache the prefetched ResultSet
                $row->{_relationship_cache} ||= {};
                $row->{_relationship_cache}{$rel_name} = $prefetched_rs;

                return $prefetched_rs;
            }

            # 3. LAZY LOAD: Build the relationship condition
            my $fk = $row->_extract_foreign_key($cond);
            unless ($fk) {
                my $rel_source = $row->_get_source->related_source($rel_name);
                my $rs = $row->{schema}->resultset($rel_source->source_name)->search({});

                # Don't cache if we couldn't extract FK
                return $rs;
            }

            my $fk_value = $row->get_column($fk->{self});
            my $related_cond = { $fk->{foreign} => $fk_value, %$extra_cond };

            # 4. Create new ResultSet for lazy loading
            my $rel_source = $row->_get_source->related_source($rel_name);
            my $rs = $row->{async_db}->resultset($rel_source->source_name);

            # 5. Cache the ResultSet (only if no extra conditions)
            if (!%$extra_cond) {
                $row->{_relationship_cache} ||= {};
                $row->{_relationship_cache}{$rel_name} = $rs;
            }

            return $rs;
        };
    }

    # Default fallback
    return sub {
        require Carp;
        Carp::croak("Unknown relationship type for '$rel_name'");
    };
}

=head2 _ensure_accessors

    $row->_ensure_accessors;

Creates accessor methods for all columns in the result source.

=cut

sub _ensure_accessors {
    my $self   = shift;
    my $source = $self->_get_source;

    return unless blessed($source) && $source->can('can');

    my $class  = ref($self);

    return unless $class;

    # 1. Handle Columns
    if ($source->can('columns')) {
        foreach my $col ($source->columns) {
            no strict 'refs';
            next if defined &{"${class}::$col"}; # Skip if already installed

            my $column_name = $col;
            no warnings 'redefine';
            *{"${class}::$column_name"} = sub {
                my $inner = shift;
                return @_ ? $inner->set_column($column_name, shift)
                          : $inner->get_column($column_name);
            };
        }
    }

    # 2. Handle Relationships
    if ($source->can('relationships')) {
        foreach my $rel ($source->relationships) {
            no strict 'refs';
            no warnings 'redefine';

            # Force redefine even if it exists (to clobber DBIC's sync method)
            my $rel_name = $rel;
            my $rel_info = $source->relationship_info($rel_name);

            *{"${class}::$rel_name"} = sub {
                my ($inner, @args) = @_;

                # PRIORITY: If we have a prefetched object, return it -
                # UNLESS the relationship has a where condition containing
                # raw SQL refs (e.g. \'datetime("now")'). Such conditions
                # cannot be evaluated in Perl, so we must skip the cache
                # and let the DB apply the filter via a fresh async fetch.
                # This fixes the RT#63709-style silent drop where datetime
                # filters on has_many relationships were ignored entirely.
                if (exists $inner->{_related}{$rel_name}) {
                    my $rel_where = $rel_info->{attrs}{where};
                    return $inner->{_related}{$rel_name}
                        unless $rel_where && _where_has_sql_refs($rel_where);
                }

                # FALLBACK: Perform async fetch if not prefetched,
                # or if the relationship where contains raw SQL refs.
                return $inner->_fetch_relationship_async($rel_name, $rel_info, @args);
            };
        }
    }
}

# Returns true if a where hashref contains any raw SQL scalar refs
# such as \'datetime("now")' that cannot be evaluated in Perl.
sub _where_has_sql_refs {
    my ($where) = @_;
    return 0 unless ref $where eq 'HASH';
    for my $val (values %$where) {
        return 1 if ref $val eq 'SCALAR' || ref $val eq 'REF';
        return 1 if ref $val eq 'HASH' && _where_has_sql_refs($val);
    }
    return 0;
}

sub _install_prefetch_accessors {
    my ($self) = @_;
    my $class  = ref($self);
    my $source = $self->result_source;

    foreach my $rel_name (keys %{$self->{_relationship_data} // {}}) {
        my $rel_info = $source->relationship_info($rel_name);
        next unless $rel_info;

        no strict 'refs';
        no warnings 'redefine';

        *{"${class}::$rel_name"} = sub {
            my ($inner_self) = @_;
            my $data = $inner_self->{_relationship_data}{$rel_name};

            # Check if this is a 'multi' (has_many) relationship
            my $is_multi = ($rel_info->{attrs}{accessor} // '') eq 'multi';

            if ($is_multi) {
                # For 'multi', the prefetch logic in ResultSet.pm should have
                # already converted the raw data into a ResultSet object.
                # If it didn't, we fallback to a normal related_resultset.
                return $data if Scalar::Util::blessed($data) && $data->isa('DBIx::Class::Async::ResultSet');
                return $inner_self->related_resultset($rel_name);
            }

            # Handle Single relationships (belongs_to / user)
            # This is what Order 3 and 4 need.
            if (Scalar::Util::blessed($data)) {
                return Future->done($data);
            }
            if (ref($data) eq 'HASH') {
                my $rs = $inner_self->{_schema_instance}->resultset($rel_info->{source});
                my $obj = $rs->new_result($data);
                $inner_self->{_relationship_data}{$rel_name} = $obj;
                return Future->done($obj);
            }

            # Fallback for singles
            return Future->done($data) if defined $data;
            return $inner_self->related_resultset($rel_name);
        };
    }
}

sub _fetch_relationship_async {
    my ($self, $rel_name, $rel_info, $attrs) = @_;

    # 1. Handle Metadata first
    $rel_info //= $self->_get_source->relationship_info($rel_name);
    my $acc_type  = $rel_info->{attrs}{accessor} // '';
    my $is_single = ($acc_type eq 'single' || $acc_type eq 'filter');

    # 2. Cache Hit Logic
    # Skip cache if relationship has raw SQL refs in where condition
    # (e.g. \'datetime("now")') - these cannot be evaluated in Perl
    # and must go to the DB to be applied correctly every time.
    my $rel_where     = $rel_info->{attrs}{where};
    my $has_sql_where = $rel_where && _where_has_sql_refs($rel_where);

    if (!$has_sql_where && exists $self->{_related}{$rel_name}) {
        my $cached = $self->{_related}{$rel_name};
        # ONLY wrap in Future if it's a single row
        return $is_single ? Future->done($cached) : $cached;
    }

    # 3. Resolve search params
    my $target_source = $rel_info->{source};
    my $cond          = $rel_info->{cond};
    my %search_params;
    while (my ($f_key, $s_key) = each %$cond) {
        my $f_col = $f_key; $f_col =~ s/^foreign\.//;
        my $s_col = $s_key; $s_col =~ s/^self\.//;
        $search_params{$f_col} = $self->get_column($s_col);
    }

    # 4. Merge relationship where into search attrs
    # Previously rel_info->{attrs}{where} was never passed to search(),
    # so datetime filters and any other where on the relationship definition
    # were silently dropped on every async fetch.
    # Only build %search_attrs if we actually have a rel_where to merge -
    # passing an empty hashref instead of undef can interfere with other
    # attrs-based mechanisms such as _has_dynamic_sql cache detection.
    my %search_attrs = %{ $attrs // {} };
    if ($rel_where) {
        if (exists $search_attrs{where}) {
            $search_attrs{where} = { -and => [ $search_attrs{where}, $rel_where ] };
        }
        else {
            $search_attrs{where} = $rel_where;
        }
    }

    if ($is_single) {
        return $self->{_schema_instance}->resultset($target_source)
            ->search(\%search_params, { %search_attrs, rows => 1 })
            ->next
            ->then(sub {
                my $row = shift;
                $self->{_related}{$rel_name} = $row unless $has_sql_where;
                return Future->done($row);
            });
    }
    else {
        # Multi returns the RS directly (synchronously).
        # Only pass \%search_attrs if it contains something - passing an
        # empty hashref instead of the original $attrs risks discarding
        # caller-provided attrs such as +select with dynamic SQL, which
        # would break _has_dynamic_sql cache detection in ResultSet.pm.
        my $effective_attrs = %search_attrs ? \%search_attrs : $attrs;
        my $rs = $self->{_schema_instance}->resultset($target_source)
                                          ->search(\%search_params, $effective_attrs);

        $self->{_related}{$rel_name} = $rs unless $has_sql_where;
        return $rs;
    }
}

=head2 _extract_foreign_key

Extracts foreign key mapping from a relationship condition.

=cut

sub _extract_foreign_key {
    my ($self, $cond) = @_;

    return undef unless $cond;

    # Handle simple foreign key condition: { 'foreign.id' => 'self.user_id' }
    if (ref $cond eq 'HASH') {
        my ($foreign_col) = keys %$cond;
        my $self_col = $cond->{$foreign_col};

        # Handle case where self_col is a reference (e.g., { '=' => 'self.user_id' })
        if (ref $self_col eq 'HASH') {
            # Extract the actual column name from the hash
            my ($op, $col) = %$self_col;
            $self_col = $col;
        }

        # Strip prefixes if present
        $foreign_col =~ s/^foreign\.//;
        $self_col =~ s/^self\.// if defined $self_col && !ref $self_col;

        return {
            foreign => $foreign_col,
            self => $self_col,
        };
    }

    # Handle code ref conditions (more complex relationships)
    # For now, we'll just return undef and let the relationship fail gracefully
    return undef;
}

=head2 _get_primary_key_info

    my $pk_info = $row->_get_primary_key_info;

Returns information about the primary key(s) for this row.

=over 4

=item B<Returns>

Hash reference with keys:

- C<columns>: Array reference of primary key column names

- C<count>: Number of primary key columns

- C<is_composite>: Boolean indicating composite primary key

=back

=cut

sub _get_primary_key_info {
    my $self   = shift;
    my $source = $self->_get_source or return;

    # CRITICAL: Call primary_columns in LIST context
    my @primary_columns = $source->primary_columns;

    return {
        columns      => \@primary_columns,
        count        => scalar @primary_columns,
        is_composite => scalar @primary_columns > 1,
    };
}

=head2 _get_source

    my $source = $row->_get_source;

Returns the result source for this row, loading it lazily if needed.

=cut

sub _get_source {
    my $self = shift;

    # 1. Return cached source if already found
    return $self->{_result_source} if $self->{_result_source};

    my $schema = $self->{_schema_instance};
    my $name   = $self->{_source_name};

    # 2. Guard: if we don't have a schema or name, we can't proceed
    return undef unless $schema && $name;

    # 3. Attempt to fetch from the schema
    my $source = eval { $schema->source($name) };

    # 4. Final verification: Only cache if it's a real, blessed object
    if ($source && blessed($source)) {
        return $self->{_result_source} = $source;
    }

    return undef;
}

#
#
# REALLY PRIVATE METHODS

sub _check_response {
    my ($self, $res) = @_;
    return undef unless ref $res;

    # 1. Handle DBIx::Class::Exception objects
    if (blessed($res) && $res->isa('DBIx::Class::Exception')) {
        return $res->msg;
    }

    # 2. Handle HashRef error envelopes ({ __error => "..." })
    if (ref $res eq 'HASH' && (my $err = $res->{error} // $res->{__error})) {
        return $err;
    }

    return undef;
}

sub _is_internal {
    my ($self, $col) = @_;

    return $col =~ $INTERNAL_KEYS;
}

=head1 PERFORMANCE OPTIMISATIONS

=over 4

=item * Shadow Keys

For performance, non-inflated columns are shadowed directly onto the object
hash, allowing for fast access without method calls.

=item * Inflation Map

The object maintains an internal C<_inflation_map> to avoid repeated metadata
lookups against the C<ResultSource>.

=item * Warm-up

The constructor pre-calculates metadata to ensure the first data access is
as fast as subsequent ones.

=back

=head1 AUTHOR

Mohammad Sajid Anwar, C<< <mohammad.anwar at yahoo.com> >>

=head1 REPOSITORY

L<https://github.com/manwar/DBIx-Class-Async>

=head1 BUGS

Please report any bugs or feature requests through the web interface at L<https://github.com/manwar/DBIx-Class-Async/issues>.
I will  be notified and then you'll automatically be notified of progress on your
bug as I make changes.

=head1 SUPPORT

You can find documentation for this module with the perldoc command.

    perldoc DBIx::Class::Async::Row

You can also look for information at:

=over 4

=item * BUG Report

L<https://github.com/manwar/DBIx-Class-Async/issues>

=item * CPAN Ratings

L<http://cpanratings.perl.org/d/DBIx-Class-Async>

=item * Search MetaCPAN

L<https://metacpan.org/dist/DBIx-Class-Async/>

=back

=head1 LICENSE AND COPYRIGHT

Copyright (C) 2026 Mohammad Sajid Anwar.

This program  is  free software; you can redistribute it and / or modify it under
the  terms  of the the Artistic License (2.0). You may obtain a  copy of the full
license at:
L<http://www.perlfoundation.org/artistic_license_2_0>
Any  use,  modification, and distribution of the Standard or Modified Versions is
governed by this Artistic License.By using, modifying or distributing the Package,
you accept this license. Do not use, modify, or distribute the Package, if you do
not accept this license.
If your Modified Version has been derived from a Modified Version made by someone
other than you,you are nevertheless required to ensure that your Modified Version
 complies with the requirements of this license.
This  license  does  not grant you the right to use any trademark,  service mark,
tradename, or logo of the Copyright Holder.
This license includes the non-exclusive, worldwide, free-of-charge patent license
to make,  have made, use,  offer to sell, sell, import and otherwise transfer the
Package with respect to any patent claims licensable by the Copyright Holder that
are  necessarily  infringed  by  the  Package. If you institute patent litigation
(including  a  cross-claim  or  counterclaim) against any party alleging that the
Package constitutes direct or contributory patent infringement,then this Artistic
License to you shall terminate on the date that such litigation is filed.
Disclaimer  of  Warranty:  THE  PACKAGE  IS  PROVIDED BY THE COPYRIGHT HOLDER AND
CONTRIBUTORS  "AS IS'  AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. THE IMPLIED
WARRANTIES    OF   MERCHANTABILITY,   FITNESS   FOR   A   PARTICULAR  PURPOSE, OR
NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY YOUR LOCAL LAW. UNLESS
REQUIRED BY LAW, NO COPYRIGHT HOLDER OR CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT,
INDIRECT, INCIDENTAL,  OR CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE
OF THE PACKAGE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

=cut

1; # End of DBIx::Class::Async::Row
