package Log::Abstraction;

use strict;
use warnings;
use Carp;	# Import Carp for warnings
use Config::Abstraction 0.36;
use Data::Dumper;
use Params::Get 0.13;	# Import Params::Get for parameter handling
use POSIX qw(strftime);
use Readonly::Values::Syslog 0.03;
use Return::Set;
use Scalar::Util 'blessed';	# Import Scalar::Util for object reference checking
use Sys::Syslog 0.28;	# Import Sys::Syslog for syslog support

=head1 NAME

Log::Abstraction - Logging Abstraction Layer

=head1 VERSION

0.30

=cut

our $VERSION = 0.30;

=head1 SYNOPSIS

  use Log::Abstraction;

  my $logger = Log::Abstraction->new(logger => 'logfile.log');

  $logger->debug('This is a debug message');
  $logger->info('This is an info message');
  $logger->notice('This is a notice message');
  $logger->trace('This is a trace message');
  $logger->warn({ warning => 'This is a warning message' });

=head1 DESCRIPTION

The C<Log::Abstraction> class provides a flexible logging layer on top of different types of loggers,
including code references, arrays, file paths, and objects.
It also supports logging to syslog if configured.

=head1 METHODS

=head2 new

    my $logger = Log::Abstraction->new(%args);

Creates a new C<Log::Abstraction> object.

The argument can be a hash,
a reference to a hash or the C<logger> value.
The following arguments can be provided:

=over

=item * C<carp_on_warn>

If set to 1,
and C<logger> is not given,
call C<Carp:carp> on C<warn()>.

Causes C<error()> to C<carp> if C<croak_on_error> is not given.

=item * C<croak_on_error>

If set to 1,
and C<logger> is not given,
call C<Carp:croak> on C<error()>.

=item * C<config_file>

Points to a configuration file which contains the parameters to C<new()>.
The file can be in any common format,
including C<YAML>, C<XML>, and C<INI>.
This allows the parameters to be set at run time.

On a non-Windows system,
the class can be configured using environment variables starting with C<"Log::Abstraction::">.
For example:

  export Log::Abstraction::script_name=foo

It doesn't work on Windows because of the case-insensitive nature of that system.

=item * C<level>

The minimum level at which to log something,
the default is "warning".

=item * C<logger>

A logger can be one or more of:

=over

=item * a code reference

The code will be called with a hashref containing:

=over

=item * class

=item * file

=item * line

=item * level

=item * message - an arrayref of messages

=item * ctx - passed to C<new()>, an argument that can help to give context to the caller

=back

=item * an object

=item * a hash of options

=item * sendmail - send higher priority messages to an email address

To send an e-mail,
you need L<require Email::Simple>, L<require Email::Sender::Simple> and L<Email::Sender::Transport::SMTP>.

The C<sendmail> hash also accepts a C<min_interval> key (seconds).
When set, at most one email is sent per C<min_interval> seconds; any
messages that arrive during the cooldown are still logged to other
backends but do not trigger a new email.
The send time is stored in C<_last_email_sent> on the object, so each
instance has its own cooldown window; cloned objects inherit the
parent's last-send timestamp at the moment of cloning.

=item * array - a reference to an array

=item * fd - containing a file descriptor to log to

=item * file - containing the filename

=back

Defaults to L<Log::Log4perl>.
In that case,
the argument 'verbose' to new() will raise the logging level.

=item * C<format>

The format of the message.
Expands:

=over

=item * %callstack%

=item * %level%

=item * %class%

=item * %message%

=item * %timestamp%

=%item * %env_foo%

Replaces with C<$ENV{foo}>

=back

=item * C<syslog>

A hash reference for syslog configuration.
Only warnings and above will be sent to syslog.
This restriction should be lifted in the future,
since it's reasonable to send notices and above to the syslog.

=item * C<script_name>

The name of the script.
It's needed when C<syslog> is given,
if none is passed, the value is guessed.

=back

Clone existing objects with or without modifications:

    my $clone = $logger->new();

=cut

sub new {
	my $class = shift;

	# Handle hash or hashref arguments
	my %args;

	if((scalar(@_) == 1) && (ref($_[0]) ne 'HASH')) {
		$args{'logger'} = shift;
	} elsif(my $params = Params::Get::get_params(undef, \@_)) {
		%args = %{$params};
	}

	# Load the configuration from a config file, if provided
	if(exists($args{'config_file'})) {
		# my $config = YAML::XS::LoadFile($params->{'config_file'});
		if(!-r $args{'config_file'}) {
			croak("$class: ", $args{'config_file'}, ': File not readable');
		}
		if(my $config = Config::Abstraction->new(config_dirs => [''], config_file => $args{'config_file'}, env_prefix => "${class}::")) {
			$config = $config->all();
			if($config->{$class}) {
				$config = $config->{$class};
			}
			my $array = $args{'array'};
			%args = (%{$config}, %args);
			if($array) {
				$args{'array'} = $array;
			}
		} else {
			croak("$class: Can't load configuration from ", $args{'config_file'});
		}
	}

	if(!defined($class)) {
		# Using Log::Abstraction:new(), not Log::Abstraction->new()
		$class = __PACKAGE__;
	} elsif(Scalar::Util::blessed($class)) {
		# If $class is an object, clone it with new arguments
		my $clone = bless { %{$class}, %args }, ref($class);
		if(my $level = $args{'level'}) {
			# The clone is at a different level
			$level = lc($level);
			if(!defined($syslog_values{$level})) {
				Carp::croak("$class: invalid syslog level '$level'");
			}
			$clone->{level} = $syslog_values{$level};
		}
		$clone->{messages} = [ @{$class->{messages}} ];	# Deep copy
		return $clone;
	}

	if($args{'syslog'} && !$args{'script_name'}) {
		require File::Basename && File::Basename->import() unless File::Basename->can('basename');

		# Determine script name
		$args{'script_name'} = File::Basename::basename($ENV{'SCRIPT_NAME'} || $0);

		croak("$class: syslog needs to know the script name") if(!defined($args{'script_name'}));
	}

	my $level = $args{'level'};
	if(defined(my $logger = $args{logger})) {
		if(Scalar::Util::blessed($logger) && (ref($logger) eq __PACKAGE__)) {
			croak("$class: attempt to encapulate ", __PACKAGE__, ' as a logging class, that would add a needless indirection');
		}
	} elsif((!$args{'file'}) && (!$args{'array'})) {
		# Default to Log4perl
		require Log::Log4perl;
		Log::Log4perl->import();

		# FIXME: add default minimum logging level
		Log::Log4perl->easy_init($args{verbose} ? $Log::Log4perl::DEBUG : $Log::Log4perl::ERROR);
		$args{'logger'} = Log::Log4perl->get_logger();
	}

	if($level) {
		if(ref($level) eq 'ARRAY') {
			$level = $level->[0];
		}
		$level = lc($level);
		if(!defined($syslog_values{$level})) {
			Carp::croak("$class: invalid syslog level '$level'");
		}
		$args{'level'} = $level;
	} else {
		# The default minimum level at which to log something is 'warning'
		$args{'level'} = 'warning';
	}

	# Bless and return the object
	return bless {
		messages => [],	# Initialize messages array
		%args,
		level => $syslog_values{$args{'level'}},
	}, $class;
}

=encoding utf-8

=head2 _sanitize_email_header

    my $clean_value = _sanitize_email_header($raw_value);

Internal routine to remove carriage return and line feed characters from an email header value to prevent header injection or formatting issues.

=over 4

=item * Input

Takes a single scalar value, typically a string representing an email header field.

=item * Behavior

If the input is undefined, returns `undef`. Otherwise, removes all newline characters (`\n`), carriage returns (`\r`), and CRLF pairs from the string.

=item * Output

Returns the sanitized string with CR/LF characters removed.

=back

=head3 FORMAL SPECIFICATION

If the input is undefined (∅), the output is also undefined (∅).

If the input is defined, the result is a defined string with CR and LF characters removed.

    [CHAR]

    CR, LF : CHAR
    CR == '\r'
    LF == '\n'

    STRING == seq CHAR

    SanitizeEmailHeader
        raw?: STRING
        sanitized!: STRING
        -------------------------------------------------
        sanitized! = [ c : raw? | c ≠ CR ∧ c ≠ LF ]

=cut

sub _sanitize_email_header {
	my $value = $_[0];

	return unless defined $value;
	$value =~ s/\r\n?|\n//g;	# Remove CR/LF characters

	return Return::Set::set_return($value, { type => 'string', 'matches' => qr /^[^\r\n]*$/ });
}

# Internal method to log messages. This method is called by other logging methods.
# $logger->_log($level, @messages);
# $logger->_log($level, \@messages);

sub _log {
	my ($self, $level, @messages) = @_;

	if(!UNIVERSAL::isa((caller)[0], __PACKAGE__)) {
		Carp::croak('Illegal Operation: _log is a private method');
	}

	if(!defined($syslog_values{$level})) {
		Carp::croak(ref($self), ": Invalid level '$level'");	# "Can't happen"
	}

	if($syslog_values{$level} > $self->{'level'}) {
		# The level is too low to log
		return;
	}

	if((scalar(@messages) == 1) && (ref($messages[0]) eq 'ARRAY')) {
		# Passed a reference to an array
		@messages = @{$messages[0]};
	}
	@messages = grep defined, @messages;

	my $str = join('', @messages);
	chomp($str);

	# Push the message to the internal messages array
	push @{$self->{messages}}, { level => $level, message => $str };

	my $class = blessed($self) || $self;
	if($class eq __PACKAGE__) {
		$class = '';
	}

	my $timestamp = strftime '%Y-%m-%d %H:%M:%S', localtime;

	if(my $logger = $self->{'logger'}) {
		if(ref($logger) eq 'CODE') {
			# If logger is a code reference, call it with log details
			my $args = {
				class => blessed($self) || __PACKAGE__,
				file => (caller(1))[1],
				# function => (caller(1))[3],
				line => (caller(1))[2],
				level => $level,
				message => \@messages
			};
			if(my $ctx = $self->{ctx}) {
				$args->{ctx} = $ctx;
			};
			$logger->($args);
		} elsif(ref($logger) eq 'ARRAY') {
			# If logger is an array reference, push the log message to the array
			push @{$logger}, { level => $level, message => $str };
		} elsif(ref($logger) eq 'HASH') {
			if(my $file = $logger->{'file'}) {
				# if($file =~ /^([-\@\w.\/\\]+)$/) {
				if($file =~ /^([^<>|*?;!`$"\0-\037]+)$/) {
					$file = $1;	# Will untaint
				} else {
					Carp::croak(ref($self), ": Invalid file name: $file");
				}
				if(open(my $fout, '>>', $logger->{'file'})) {
					my $format = $self->{'format'} || '%level%> [%timestamp%] %class% %callstack% %message%';
					my $ulevel = uc($level);
					my $callstack = (caller(1))[1] . ' ' . (caller(1))[2];

					$format =~ s/%level%/$ulevel/g;
					$format =~ s/%class%/$class/g;
					$format =~ s/%message%/$str/g;
					$format =~ s/%callstack%/$callstack/g;
					$format =~ s/%timestamp%/$timestamp/g;
					$format =~ s/%env_(\w+)%/$ENV{$1}/g;
					print $fout "$format\n" or Carp::croak(ref($self), ": Can't write to $file: $!");
					close $fout;
				}
			}
			if(my $array = $logger->{'array'}) {
				push @{$array}, { level => $level, message => $str };
			}
			if(exists($logger->{'sendmail'}) && exists($logger->{'sendmail'}->{'to'})) {
				# Send an email
				if((!defined($logger->{'sendmail'}->{'level'})) ||
				   ($syslog_values{$level} <= $syslog_values{$logger->{'sendmail'}->{'level'}})) {
					my $throttled = 0;
					if(my $min_interval = $logger->{'sendmail'}->{'min_interval'}) {
						my $now = time();
						# _last_email_sent: epoch time of the most recent successfully sent email
						$throttled = defined($self->{_last_email_sent}) && ($now - $self->{_last_email_sent}) < $min_interval;
					}
					if(!$throttled) {
						eval {
							require Email::Simple;
							require Email::Sender::Simple;
							require Email::Sender::Transport::SMTP;

							Email::Simple->import();
							Email::Sender::Simple->import('sendmail');
							Email::Sender::Transport::SMTP->import();

							my $email = Email::Simple->new('');
							$email->header_set('to', _sanitize_email_header($logger->{'sendmail'}->{'to'}));
							if(my $from = $logger->{'sendmail'}->{'from'}) {
								$email->header_set('from', _sanitize_email_header($from));
							} else {
								$email->header_set('from', 'noreply@localhost');
							}
							if(my $subject = $logger->{'sendmail'}->{'subject'}) {
								$email->header_set('subject', _sanitize_email_header($subject));
							}
							$email->body_set(join(' ', @messages));

							# Configure SMTP transport (adjust for your SMTP server)
							my $transport = Email::Sender::Transport::SMTP->new({
								host => $logger->{'sendmail'}->{'host'} || 'localhost',
								port => $logger->{'sendmail'}->{'port'} || 25
							});

							sendmail($email, { transport => $transport });
						};

						if ($@) {
							Carp::carp("Failed to send email: $@");
							return;
						}
						$self->{_last_email_sent} = time();	# record send time for throttle
					}
				}
			}
			if(my $syslog = $logger->{'syslog'}) {
				if((!defined($syslog->{'level'})) || ($syslog_values{$level} <= $syslog->{'level'})) {
					if(!$self->{_syslog_opened}) {
						# Open persistent syslog connection
						my $facility = delete $syslog->{'facility'} || 'local0';
						my $min_level = delete $syslog->{'level'};
						# CHI uses server, Sys::Syslog uses host :-(
						if($syslog->{'server'}) {
							$syslog->{'host'} = delete $syslog->{'server'};
						}
						Sys::Syslog::setlogsock($syslog) if(scalar keys %{$syslog});
						$syslog->{'facility'} = $facility;
						$syslog->{'level'} = $min_level;

						openlog($self->{script_name}, 'cons,pid', 'user');
						$self->{_syslog_opened} = 1;	# Flag to track active connection
					}

					# Handle syslog-based logging
					eval {
						my $priority = ($level eq 'error') ? 'err' : 'warning';
						my $facility = $syslog->{'facility'};
						Sys::Syslog::syslog("$priority|$facility", join(' ', @messages));
					};
					if($@) {
						my $err = $@;
						$err .= ":\n" . Data::Dumper->new([$syslog])->Dump();
						Carp::carp($err);
					}
				}
			}

			if(my $fout = $logger->{'fd'}) {
				my $format = $self->{'format'} || '%level%> [%timestamp%] %class% %callstack% %message%';
				my $ulevel = uc($level);
				my $callstack = (caller(1))[1] . ' ' . (caller(1))[2];

				$format =~ s/%level%/$ulevel/g;
				$format =~ s/%class%/$class/g;
				$format =~ s/%message%/$str/g;
				$format =~ s/%callstack%/$callstack/g;
				$format =~ s/%timestamp%/$timestamp/g;
				$format =~ s/%env_(\w+)%/$ENV{$1} \/\/ ''/ge;

				print $fout "$format\n" or Carp::croak(ref($self), ": Can't write to file descriptor: $!");
			} elsif(!$logger->{'file'} && !$logger->{'array'} && !$logger->{'syslog'} && !exists($logger->{'sendmail'}) && !$logger->{'fd'}) {
				croak(ref($self), ": Don't know how to deal with the $level message");
			}
		} elsif(!ref($logger)) {
			# If logger is a file path, append the log message to the file
			if(open(my $fout, '>>', $logger)) {
				my $format = $self->{'format'} || '%level%> [%timestamp%] %class% %callstack% %message%';
				my $ulevel = uc($level);
				my $callstack = (caller(1))[1] . ' ' . (caller(1))[2];

				$format =~ s/%level%/$ulevel/g;
				$format =~ s/%class%/$class/g;
				$format =~ s/%message%/$str/g;
				$format =~ s/%callstack%/$callstack/g;
				$format =~ s/%timestamp%/$timestamp/g;
				$format =~ s/%env_(\w+)%/$ENV{$1} \/\/ ''/ge;

				print $fout "$format\n" or Carp::croak(ref($self), ": Can't write to $logger: $!");
				close $fout;
			}
		} elsif(Scalar::Util::blessed($logger)) {
			# If logger is an object, call the appropriate method on the object
			if(!$logger->can($level)) {
				if(($level eq 'notice') && $logger->can('info')) {
					# Map notice to info for Log::Log4perl
					$level = 'info';
				} else {
					croak(ref($self), ': ', ref($logger), " doesn't know how to deal with the $level message");
				}
			}
			$logger->$level(@messages);
		} else {
			croak(ref($self), ": configuration error, no handler written for the $level message");
		}
	} elsif($self->{'array'}) {
		push @{$self->{'array'}}, { level => $level, message => $str };
	}

	if($self->{'file'}) {
		my $file = $self->{'file'};

		# Untaint the file name
		# if($file =~ /^([-\@\w.\/\\]+)$/) {
		if($file =~ /^([^<>|*?;!`$"\0-\037]+)$/) {
			$file = $1;	# untainted version
		} else {
			croak(ref($self), ": Tainted or unsafe filename: $file");
		}

		if(open(my $fout, '>>', $file)) {
			my $ulevel = uc($level);
			my $callstack = (caller(1))[1] . ' ' . (caller(1))[2];
			my $format;

			if(blessed($self) eq __PACKAGE__) {
				$format = $self->{'format'} || '%level%> [%timestamp%] %callstack% %message%';
			} else {
				$format = $self->{'format'} || '%level%> [%timestamp%] %class% %callstack% %message%';
			}

			$format =~ s/%level%/$ulevel/g;
			$format =~ s/%class%/$class/g;
			$format =~ s/%message%/$str/g;
			$format =~ s/%callstack%/$callstack/g;
			$format =~ s/%timestamp%/$timestamp/g;
			$format =~ s/%env_(\w+)%/$ENV{$1} \/\/ ''/ge;

			print $fout "$format\n" or Carp::croak("ref($self): Can't write to ", $self->{'file'}, ": $!");
			close $fout;
		}
	}
	if(my $fout = $self->{'fd'}) {
		my $ulevel = uc($level);
		my $callstack = (caller(1))[1] . ' ' . (caller(1))[2];
		my $format;

		if(blessed($self) eq __PACKAGE__) {
			$format = $self->{'format'} || '%level%> [%timestamp%] %callstack% %message%';
		} else {
			$format = $self->{'format'} || '%level%> [%timestamp%] %class% %callstack% %message%';
		}

		$format =~ s/%level%/$ulevel/g;
		$format =~ s/%class%/$class/g;
		$format =~ s/%message%/$str/g;
		$format =~ s/%callstack%/$callstack/g;
		$format =~ s/%timestamp%/$timestamp/g;
		$format =~ s/%env_(\w+)%/$ENV{$1} \/\/ ''/ge;

		print $fout "$format\n" or Carp::croak(ref($self), ": Can't write to file descriptor: $!");
	}
}

=head2 level($self, $level)

Get/set the minimum level to log at.
Returns the current level, as an integer.

=cut

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

	if($level) {
		if(!defined($syslog_values{$level})) {
			Carp::carp(ref($self), ": invalid syslog level '$level'");
			return;
		}
		$self->{'level'} = $syslog_values{$level};
	}
	return Return::Set::set_return($self->{'level'}, { 'type' => 'integer', 'min' => 0, 'max' => 7 });
}

=head2	is_debug

Are we at a debug level that will emit debug messages?
For compatibility with L<Log::Any>.

=cut

sub is_debug
{
	my $self = $_[0];

	return ($self->{'level'} && ($self->{'level'} >= $DEBUG)) ? 1 : 0;
}

=head2 messages

Return all the messages emitted so far

=cut

sub messages
{
	my $self = $_[0];

	return [ @{$self->{messages}} ];	# Return a shallow copy
}

=head2 debug

  $logger->debug(@messages);

Logs a debug message.

=cut

sub debug {
	my $self = shift;
	$self->_log('debug', @_);
}

=head2 info

  $logger->info(@messages);

Logs an info message.

=cut

sub info {
	my $self = shift;
	$self->_log('info', @_);
}

=head2 notice

  $logger->notice(@messages);

Logs a notice message.

=cut

sub notice {
	my $self = shift;
	$self->_log('notice', @_);
}

=head2 error

    $logger->error(@messages);

Logs an error message. This method also supports logging to syslog if configured.
If not logging mechanism is set,
falls back to C<croak>.

=cut

sub error {
	my $self = shift;

	$self->_high_priority('error', @_);
}

=head2 fatal

    $logger->fatal(@messages);

Synonym of error.

=cut

sub fatal {
	my $self = shift;

	$self->_high_priority('error', @_);
}

=head2 trace

  $logger->trace(@messages);

Logs a trace message.

=cut

sub trace {
	my $self = shift;
	$self->_log('trace', @_);
}

=head2 warn

  $logger->warn(@messages);
  $logger->warn(\@messages);
  $logger->warn(warning => \@messages);

Logs a warning message. This method also supports logging to syslog if configured.
If not logging mechanism is set,
falls back to C<Carp>.

=cut

sub warn {
	my $self = shift;

	return if(scalar(@_) == 0);
	$self->_high_priority('warn', @_);
}

=head2 _high_priority

Helper to handle important messages.

=cut

sub _high_priority
{
	my $self = shift;
	my $level = shift;	# 'warn' or 'error'

	return if(scalar(@_) == 0);	# No message - return quickly

	# Only logging things at warning or higher
	return if($syslog_values{$level} > $WARNING);

	my $warning;

	# Check if called as warn(warning => ...) or warn('plain', 'args')
	my $params;
	eval { $params = Params::Get::get_params('warning', @_) };
	if($@ || !$params || ref($params) ne 'HASH' || !exists($params->{warning})) {
		# Plain list form — join directly
		$warning = join('', grep { defined } @_);
		return unless length($warning);
	} else {
		$warning = $params->{warning};
		return unless defined($warning);
		if(ref($warning) eq 'ARRAY') {
			$warning = join('', grep { defined } @{$warning});
		}
	}

	if($params && ref($params) eq 'HASH' && exists($params->{warning})) {
		$warning = $params->{warning};
		return unless defined($warning);	# warn(warning => undef) → no-op
		if(ref($warning) eq 'ARRAY') {
			# Given "warning => [ ref to array ]"
			$warning = join('', grep { defined } @{$warning});
		}
	} else {
		# Plain list: warn('This ', 'is ', 'a ', 'list')
		# Filter undefs and join
		$warning = join('', grep { defined } @_);
		return unless length($warning);		# all-undef list → no-op
	}

	if($self eq __PACKAGE__) {
		# If called from a class method, use croak/carp to warn
		if($syslog_values{$level} <= $ERROR) {
			Carp::croak($warning);
		}
		Carp::carp($warning);
		return;
	}

	# Log the warning message
	$self->_log($level, $warning);

	if($syslog_values{$level} <= $ERROR) {
		# Fall back to croak if no logger or syslog is defined
		if($self->{'croak_on_error'} || (!defined($self->{logger}) && (!defined($self->{array})))) {
			Carp::croak($warning);
		}
	}

	if($self->{'carp_on_warn'} || (!defined($self->{logger}) && (!defined($self->{array})))) {
		# Fallback to Carp if no logger or syslog is defined
		Carp::carp($warning);
	}
}

# Destructor to close syslog connection
sub DESTROY {
	my $self = $_[0];

	if($self->{_syslog_opened}) {
		Sys::Syslog::closelog();
		delete $self->{_syslog_opened};
	}
}

=head1 EXAMPLES

=head2 CSV file logging for BI import

The code-reference backend gives you full control over the output format.
The example below writes every message at C<trace> level and above as a
CSV row to a file,
producing output that can be loaded directly into a spreadsheet or BI tool
(Tableau, Power BI, Metabase, etc.).

Each row contains: C<timestamp>, C<level>, C<class>, C<file>, C<line>, C<message>.

  use Log::Abstraction;

  my $csv_file = 'app_events.csv';

  # Write the header row once (skip if the file already exists and has data).
  unless (-s $csv_file) {
      open my $fh, '>', $csv_file or die "Cannot open $csv_file: $!";
      print $fh qq{timestamp,level,class,file,line,message\n};
      close $fh;
  }

  # Helper: quote a single CSV field (escapes embedded double-quotes).
  my $csv_field = sub {
      my $v = defined $_[0] ? $_[0] : '';
      $v =~ s/"/""/g;
      return qq{"$v"};
  };

  my $logger = Log::Abstraction->new(
      level  => 'trace',        # capture everything from trace upwards
      logger => sub {
          my $args = $_[0];

          my $timestamp = POSIX::strftime('%Y-%m-%dT%H:%M:%SZ', gmtime);
          my $message  = join(' ', @{ $args->{message} // [] });

          open my $fh, '>>', $csv_file or return;
          print $fh join(',',
              $csv_field->($timestamp),
              $csv_field->($args->{level}),
              $csv_field->($args->{class}),
              $csv_field->($args->{file}),
              $csv_field->($args->{line}),
              $csv_field->($message),
          ), "\n";
          close $fh;
      },
  );

  $logger->trace('application started');
  $logger->info('user logged in', { user => 'alice' });
  $logger->warn({ warning => 'disk usage above 80%' });

The resulting C<app_events.csv> looks like:

  timestamp,level,class,file,line,message
  "2026-05-27T14:00:00Z","trace","main","app.pl","42","application started"
  "2026-05-27T14:00:01Z","info","main","app.pl","43","user logged in"
  "2026-05-27T14:00:02Z","warning","main","app.pl","44","disk usage above 80%"

For production use, consider replacing the manual C<$csv_field> quoting with
L<Text::CSV> for correct handling of embedded newlines and other edge cases.

If you also want real-time alerting on critical events,
combine the code-ref backend with the C<sendmail> backend:

  my $logger = Log::Abstraction->new(
      level  => 'trace',
      logger => {
          file    => $csv_file,   # use the file backend for simple append
          sendmail => {
              host         => 'smtp.example.com',
              to           => 'ops@example.com',
              from         => 'logger@example.com',
              subject      => 'Application alert',
              min_interval => 300,   # at most one alert email per 5 minutes
          },
      },
  );

In this configuration every message at C<trace> and above is appended to the
file,
while C<warn> / C<error> messages additionally trigger an email (throttled to
one per five minutes).

=head1 AUTHOR

Nigel Horne C<njh@nigelhorne.com>

=head1 SEE ALSO

=over 4

=item * L<Test Dashboard|https://nigelhorne.github.io/Log-Abstraction/coverage/>

=back

=head1 SUPPORT

This module is provided as-is without any warranty.

Please report any bugs or feature requests to C<bug-log-abstraction at rt.cpan.org>,
or through the web interface at
L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Log-Abstraction>.
I will be notified, and then you'll
automatically be notified of progress on your bug as I make changes.

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

    perldoc Log::Abstraction

You can also look for information at:

=over 4

=item * MetaCPAN

L<https://metacpan.org/dist/Log-Abstraction>

=item * RT: CPAN's request tracker

L<https://rt.cpan.org/NoAuth/Bugs.html?Dist=Log-Abstraction>

=item * CPAN Testers' Matrix

L<http://matrix.cpantesters.org/?dist=Log-Abstraction>

=item * CPAN Testers Dependencies

L<http://deps.cpantesters.org/?module=Log::Abstraction>

=back

=head1 COPYRIGHT AND LICENSE

Copyright (C) 2025-2026 Nigel Horne

Usage is subject to the GPL2 licence terms.
If you use it,
please let me know.

=cut

1;
