#!/usr/bin/perl
#
# File audit report builder.
#
use strict;
use utf8;
use Getopt::Long;
use File::Path qw(make_path);
use File::Spec;
use Time::Local;
use Time::HiRes qw(time);

require "/usr/local/teamfile/www/bin/mt-lib.pl";

my $AUDIT_ROOT = '/var/lib/teamfile/audit';
my $CONFIG_FILE = $AUDIT_ROOT . '/fileauditor.conf';
my $LOCK_FILE = getLogPath() . '/fileauditor.pid';
my $LOG_FILE  = getJobsWorkPath() . '/fileauditor.log';

my $F_LOG = undef;
my $opt_h = undef;
my $opt_v = undef;
my $opt_f = undef;
my $opt_hour = undef;
my $opt_min = undef;
my $opt_target_date = undef;
my $opt_location = undef;

my $DEFAULT_HOUR = 3;
my $DEFAULT_MIN = 35;
my $DISK_WARN_RATE = 80;
my $DISK_CRITICAL_RATE = 90;
my $LARGE_FILE_BYTES = 100 * 1024 * 1024;
my $HUGE_FILE_BYTES = 500 * 1024 * 1024;
my $STALE_DAYS = 180;
my $VERY_STALE_DAYS = 365;
my $BOX_LONG_OPEN_DAYS = 90;
my $DETAIL_LIMIT = 20;
my $TOP_USER_LIMIT = 10;
my $AUDIT_OWNER = 'teamfile';
my $BOX_FLAG_OPEN = 0x01;
my $PSQL = '/usr/local/teamfile/tfpgsql/bin/psql';

my $ret = GetOptions(
	"h" => \$opt_h,
	"v" => \$opt_v,
	"f" => \$opt_f,
	"hour=i" => \$opt_hour,
	"min=i" => \$opt_min,
	"target-date=s" => \$opt_target_date,
	"location=s" => \$opt_location,
);
if (!$ret || $opt_h) {
	usage();
	exit($ret ? 0 : 1);
}
chdir('/');

exit(main());

sub main {
	ensure_audit_config();
	my %conf = read_audit_config();
	my $hour = defined($opt_hour) ? $opt_hour : ($conf{hour} || $DEFAULT_HOUR);
	my $min = defined($opt_min) ? $opt_min : ($conf{min} || $DEFAULT_MIN);
	my $target_date = normalize_target_date($opt_target_date || $conf{target_date} || yesterday_ymd());
	my $now = time;

	if (!$opt_f && !isItTimeToRunJob($now, $hour, $min)) {
		printerr("Skipped. schedule=${hour}:${min}\n");
		return 0;
	}

	my $cRetry = 0;
	while (!isRunning(getApachePidPath())) {
		sleep 1;
		if ($cRetry++ >= 30) {
			printerr("Apache had been stopped. we give up execution of file auditor.\n");
			return 1;
		}
	}

	if ((my $pid = lockScript($LOCK_FILE)) != 0) {
		printerr("This script had been already started by $pid.\n");
		return 1;
	}

	initLog();
	ensure_audit_dir($AUDIT_ROOT);

	my %gparams = readTfGlobalSettings();
	my @lcfiles = getLocationConflist();
	my $ok_count = 0;
	my $fail_count = 0;

	for my $lcfile (@lcfiles) {
		my %lcparams = parseLocationConf($lcfile, \%gparams);
		next if !%lcparams || !$lcparams{Location};
		next if $opt_location && $opt_location ne $lcparams{Location};

		my %dbparams = parse_db_location_conf($lcfile);
		my $ok = audit_location(\%lcparams, \%dbparams, $target_date);
		if ($ok) {
			$ok_count++;
		}
		else {
			$fail_count++;
		}
	}

	writeLog("Finished file auditor. ok=$ok_count failed=$fail_count target_date=$target_date");
	closeLog();
	unlockScript($LOCK_FILE);
	return $fail_count ? 1 : 0;
}

sub audit_location {
	my ($lcparams_ref, $dbparams_ref, $target_date) = @_;
	my %lcparams = %{$lcparams_ref || {}};
	my %dbparams = %{$dbparams_ref || {}};
	my $location = $lcparams{Location} || '';
	my $started_at = time;

	if (!$location) {
		writeLog("Skipped location because location name is missing.");
		return 0;
	}
	if (!has_db_config(\%dbparams)) {
		write_error($location, "Database configuration is incomplete.");
		return 0;
	}

	writeLog("Start file audit. location=$location target_date=$target_date");

	my $data_ref = eval { query_file_audit_data(\%dbparams, $location, $target_date) };
	if ($@) {
		my $err = $@;
		chomp($err);
		write_error($location, "Database query failed. $err");
		return 0;
	}

	my @issues;
	my %summary = %{$data_ref->{summary} || {}};
	my %details = %{$data_ref->{details} || {}};

	if ($summary{disk_quota_configured} && $summary{disk_size_rate} >= $DISK_CRITICAL_RATE) {
		push @issues, issue_item('critical', 'disk_usage_critical', 'Storage usage is ' . $summary{disk_size_rate} . '%.');
	}
	elsif ($summary{disk_quota_configured} && $summary{disk_size_rate} >= $DISK_WARN_RATE) {
		push @issues, issue_item('warn', 'disk_usage_warn', 'Storage usage is ' . $summary{disk_size_rate} . '%.');
	}
	if (!$summary{disk_quota_configured}) {
		push @issues, issue_item('caution', 'disk_quota_unlimited', 'Storage quota is not configured.');
	}
	if ($summary{box_expired_open} > 0) {
		push @issues, issue_item('critical', 'box_expired_open', $summary{box_expired_open} . ' BOX entries are still open after expiration.');
	}
	if ($summary{box_unlimited_open} > 0) {
		push @issues, issue_item('critical', 'box_unlimited_open', $summary{box_unlimited_open} . ' BOX entries are open without expiration.');
	}
	if ($summary{box_long_open} > 0) {
		push @issues, issue_item('warn', 'box_long_open', $summary{box_long_open} . ' BOX entries have long public expiration.');
	}
	if ($summary{huge_files} > 0) {
		push @issues, issue_item('warn', 'huge_files', $summary{huge_files} . ' files are larger than ' . format_bytes($HUGE_FILE_BYTES) . '.');
	}
	if ($summary{large_files} > 0) {
		push @issues, issue_item('caution', 'large_files', $summary{large_files} . ' files are larger than ' . format_bytes($LARGE_FILE_BYTES) . '.');
	}
	if ($summary{very_stale_files} > 0) {
		push @issues, issue_item('warn', 'very_stale_files', $summary{very_stale_files} . ' files have not been updated for ' . $VERY_STALE_DAYS . '+ days.');
	}
	elsif ($summary{stale_files} > 0) {
		push @issues, issue_item('caution', 'stale_files', $summary{stale_files} . ' files have not been updated for ' . $STALE_DAYS . '+ days.');
	}
	if (!@issues) {
		push @issues, issue_item('ok', 'no_problem', 'No major findings.');
	}

	my $elapsed_ms = int((time - $started_at) * 1000);
	my $result = {
		version => 1,
		type => 'file-audit',
		location => $location,
		generated_at => iso8601_now(),
		target_date => $target_date,
		elapsed_ms => $elapsed_ms,
		status => 'ok',
		rules => {
			disk_warn_rate => $DISK_WARN_RATE,
			disk_critical_rate => $DISK_CRITICAL_RATE,
			large_file_bytes => $LARGE_FILE_BYTES,
			huge_file_bytes => $HUGE_FILE_BYTES,
			stale_days => $STALE_DAYS,
			very_stale_days => $VERY_STALE_DAYS,
			box_long_open_days => $BOX_LONG_OPEN_DAYS,
		},
		summary => \%summary,
		issues => \@issues,
		details => \%details,
	};

	write_result($location, $result);
	writeLog("Finished file audit. location=$location disk_rate=" . ($summary{disk_size_rate} || 0) . " elapsed_ms=$elapsed_ms");
	return 1;
}

sub query_file_audit_data {
	my ($dbparams_ref, $location, $target_date) = @_;
	die "$PSQL was not found." if !-x $PSQL;

	my $location_uri = '/' . $location;
	my $like_uri = escape_sql_like($location_uri) . '/%';
	my $target_epoch = ymd_to_epoch_end($target_date);
	my $stale_epoch = $target_epoch - ($STALE_DAYS * 24 * 3600);
	my $very_stale_epoch = $target_epoch - ($VERY_STALE_DAYS * 24 * 3600);
	my $box_long_epoch = $target_epoch + ($BOX_LONG_OPEN_DAYS * 24 * 3600);

	my @disk = query_rows($dbparams_ref, <<"_SQL_");
SELECT dkqt_used_st_bi, dkqt_max_st_bi, dkqt_used_res_bi, dkqt_max_res_bi
FROM divy_diskquota
WHERE dkqt_uri_txt = '$location_uri' AND dkqt_type_i = 0
LIMIT 1
_SQL_
	my ($used_st, $max_st, $used_res, $max_res) = @{$disk[0] || [0, 0, 0, 0]};
	$used_st ||= 0; $max_st ||= 0; $used_res ||= 0; $max_res ||= 0;
	my $disk_configured = ($max_st > 0) ? 1 : 0;
	my $disk_rate = $disk_configured ? int(($used_st * 100 + ($max_st / 2)) / $max_st) : 0;

	my @user_quota = query_rows($dbparams_ref, <<"_SQL_");
SELECT
  q.usqt_usr_id_vc,
  replace(replace(COALESCE(u.usr_fullname_vc, ''), E'\\t', ' '), E'\\n', ' '),
  COALESCE(u.usr_extended_status_c, ''),
  COALESCE(q.usqt_used_st_bi, 0),
  COALESCE(q.usqt_used_res_bi, 0)
FROM divy_usrdiskquota q
INNER JOIN divy_usr u ON u.usr_usr_id_vc = q.usqt_usr_id_vc
WHERE COALESCE(q.usqt_used_st_bi, 0) > 0 OR COALESCE(q.usqt_used_res_bi, 0) > 0
ORDER BY COALESCE(q.usqt_used_st_bi, 0) DESC
LIMIT $TOP_USER_LIMIT
_SQL_
	my @top_users = map { user_quota_item($_, $used_st) } @user_quota;
	my $top_user_rate = (@top_users && $used_st > 0) ? $top_users[0]->{size_rate} : 0;

	my @inactive_quota = query_rows($dbparams_ref, <<"_SQL_");
SELECT
  q.usqt_usr_id_vc,
  replace(replace(COALESCE(u.usr_fullname_vc, ''), E'\\t', ' '), E'\\n', ' '),
  COALESCE(q.usqt_used_st_bi, 0),
  COALESCE(q.usqt_used_res_bi, 0)
FROM divy_usrdiskquota q
INNER JOIN divy_usr u ON u.usr_usr_id_vc = q.usqt_usr_id_vc
WHERE substring(COALESCE(u.usr_extended_status_c, ''), 1, 1) <> 'a'
  AND (COALESCE(q.usqt_used_st_bi, 0) > 0 OR COALESCE(q.usqt_used_res_bi, 0) > 0)
ORDER BY COALESCE(q.usqt_used_st_bi, 0) DESC
LIMIT $DETAIL_LIMIT
_SQL_
	my @inactive_users = map { inactive_user_quota_item($_) } @inactive_quota;

my @box_rows = query_rows($dbparams_ref, <<"_SQL_");
SELECT
  box.box_uri_txt,
  box.box_flag_i,
  box.box_create_bi,
  box.box_expiration_bi,
  box.box_creator_usr_id_vc,
  box.box_viewcount_bi,
  replace(replace(COALESCE(usr.usr_fullname_vc, ''), E'\\t', ' '), E'\\n', ' '),
  replace(replace(COALESCE(box.box_greeting_vc, ''), E'\\t', ' '), E'\\n', ' '),
  replace(replace(COALESCE(box.box_message_vc, ''), E'\\t', ' '), E'\\n', ' '),
  replace(replace(COALESCE(box.box_to_mailaddr_vc, ''), E'\\t', ' '), E'\\n', ' ')
FROM divy_box box
LEFT JOIN divy_usr usr ON usr.usr_usr_id_vc = box.box_creator_usr_id_vc
WHERE box.box_uri_txt = '$location_uri' OR box.box_uri_txt LIKE '$like_uri'
ORDER BY box.box_create_bi DESC
_SQL_
	my @open_boxes;
	my @expired_boxes;
	my @unlimited_boxes;
	my @long_boxes;
	for my $row (@box_rows) {
		my $box = box_item($row, $target_epoch);
		next if !$box->{open};
		push @open_boxes, $box if scalar(@open_boxes) < $DETAIL_LIMIT;
		push @expired_boxes, $box if $box->{expired} && scalar(@expired_boxes) < $DETAIL_LIMIT;
		push @unlimited_boxes, $box if $box->{unlimited} && scalar(@unlimited_boxes) < $DETAIL_LIMIT;
		push @long_boxes, $box if !$box->{unlimited} && $box->{expiration_epoch} > $box_long_epoch && scalar(@long_boxes) < $DETAIL_LIMIT;
	}

	my @large_summary = query_rows($dbparams_ref, <<"_SQL_");
SELECT
  COUNT(CASE WHEN rs_get_cont_len_bi >= $LARGE_FILE_BYTES THEN 1 END),
  COUNT(CASE WHEN rs_get_cont_len_bi >= $HUGE_FILE_BYTES THEN 1 END),
  COUNT(CASE WHEN rs_get_lastmodified_bi < $stale_epoch THEN 1 END),
  COUNT(CASE WHEN rs_get_lastmodified_bi < $very_stale_epoch THEN 1 END)
FROM dav_resource
WHERE (rs_uri_txt = '$location_uri' OR rs_uri_txt LIKE '$like_uri')
  AND rs_resourcetype_i = 0
_SQL_
	my ($large_count, $huge_count, $stale_count, $very_stale_count) = @{$large_summary[0] || [0, 0, 0, 0]};

	my @large_files = map { file_item($_, $target_epoch) } query_rows($dbparams_ref, <<"_SQL_");
SELECT rs_uri_txt, rs_get_cont_len_bi, rs_get_lastmodified_bi, rs_lastmodifier_usr_id_vc
FROM dav_resource
WHERE (rs_uri_txt = '$location_uri' OR rs_uri_txt LIKE '$like_uri')
  AND rs_resourcetype_i = 0
  AND rs_get_cont_len_bi >= $LARGE_FILE_BYTES
ORDER BY rs_get_cont_len_bi DESC
LIMIT $DETAIL_LIMIT
_SQL_
	my @stale_files = map { file_item($_, $target_epoch) } query_rows($dbparams_ref, <<"_SQL_");
SELECT rs_uri_txt, rs_get_cont_len_bi, rs_get_lastmodified_bi, rs_lastmodifier_usr_id_vc
FROM dav_resource
WHERE (rs_uri_txt = '$location_uri' OR rs_uri_txt LIKE '$like_uri')
  AND rs_resourcetype_i = 0
  AND rs_get_lastmodified_bi < $stale_epoch
ORDER BY rs_get_lastmodified_bi ASC
LIMIT $DETAIL_LIMIT
_SQL_

	return {
		summary => {
			disk_quota_configured => $disk_configured,
			disk_used_bytes => $used_st,
			disk_max_bytes => $max_st,
			disk_size_rate => $disk_rate,
			disk_used_files => $used_res,
			disk_max_files => $max_res,
			top_user_rate => $top_user_rate,
			inactive_user_quota_users => scalar(@inactive_users),
			box_total => scalar(@box_rows),
			box_open => scalar(grep { box_is_open($_) } @box_rows),
			box_expired_open => count_boxes(\@box_rows, $target_epoch, 'expired'),
			box_unlimited_open => count_boxes(\@box_rows, $target_epoch, 'unlimited'),
			box_long_open => count_boxes(\@box_rows, $target_epoch, 'long'),
			large_files => $large_count || 0,
			huge_files => $huge_count || 0,
			stale_files => $stale_count || 0,
			very_stale_files => $very_stale_count || 0,
		},
		details => {
			top_users => \@top_users,
			inactive_users => \@inactive_users,
			open_boxes => \@open_boxes,
			expired_boxes => \@expired_boxes,
			unlimited_boxes => \@unlimited_boxes,
			long_boxes => \@long_boxes,
			large_files => \@large_files,
			stale_files => \@stale_files,
		},
	};
}

sub query_rows {
	my ($dbparams_ref, $sql) = @_;
	my %dbparams = %{$dbparams_ref || {}};
	local $ENV{PGPASSWORD} = $dbparams{password} || '';
	local $ENV{PGCLIENTENCODING} = 'UTF8';

	open(my $fh, '-|',
		$PSQL,
		'-h', $dbparams{hostname},
		'-p', $dbparams{hostport},
		'-d', $dbparams{dbname},
		'-U', $dbparams{username},
		'-At',
		'-F', "\t",
		'-c', $sql) or die "Failed to execute psql: $!";

	my @rows;
	while (my $line = <$fh>) {
		chomp($line);
		push @rows, [ split(/\t/, $line, -1) ];
	}
	close($fh);
	if ($? != 0) {
		die "psql failed. ret=$?";
	}
	return @rows;
}

sub parse_db_location_conf {
	my $lcfile = shift || '';
	my %params;
	my $path = File::Spec->catfile(getConfigPath(), $lcfile);
	return %params if !-f $path;

	if (open(my $fh, '<', $path)) {
		while (my $line = <$fh>) {
			next if $line =~ /^[ \t]*#/;
			if ($line =~ /^[ \t]*TfDbDbmstype[ \t]+(.+?)\s*$/) { $params{dbmstype} = $1; }
			elsif ($line =~ /^[ \t]*TfDbHostname[ \t]+(.+?)\s*$/) { $params{hostname} = $1; }
			elsif ($line =~ /^[ \t]*TfDbHostport[ \t]+(.+?)\s*$/) { $params{hostport} = $1; }
			elsif ($line =~ /^[ \t]*TfDbDbname[ \t]+(.+?)\s*$/) { $params{dbname} = $1; }
			elsif ($line =~ /^[ \t]*TfDbUsername[ \t]+(.+?)\s*$/) { $params{username} = $1; }
			elsif ($line =~ /^[ \t]*TfDbPassword[ \t]+(.+?)\s*$/) { $params{password} = $1; }
		}
		close($fh);
	}
	return %params;
}

sub has_db_config {
	my $dbparams_ref = shift || {};
	my %dbparams = %{$dbparams_ref};
	return 0 if ($dbparams{dbmstype} || '') ne 'pg';
	return $dbparams{hostname} && $dbparams{hostport} && $dbparams{dbname} && $dbparams{username};
}

sub box_is_open {
	my $row = shift || [];
	my $flag = $row->[1] || 0;
	return ($flag & $BOX_FLAG_OPEN) ? 1 : 0;
}

sub count_boxes {
	my ($rows_ref, $target_epoch, $mode) = @_;
	my $count = 0;
	my $box_long_epoch = $target_epoch + ($BOX_LONG_OPEN_DAYS * 24 * 3600);
	for my $row (@{$rows_ref || []}) {
		next if !box_is_open($row);
		my $expiration = $row->[3] || 0;
		if ($mode eq 'expired') {
			$count++ if $expiration > 0 && $expiration < $target_epoch;
		}
		elsif ($mode eq 'unlimited') {
			$count++ if $expiration <= 0;
		}
		elsif ($mode eq 'long') {
			$count++ if $expiration > $box_long_epoch;
		}
	}
	return $count;
}

sub box_item {
	my ($row, $target_epoch) = @_;
	my $expiration = $row->[3] || 0;
	my $uri = $row->[0] || '';
	my $name = $row->[7] || final_path_segment($uri);
	my $creator_id = $row->[4] || '';
	my $creator_name = $row->[6] || '';
	return {
		uri => $uri,
		group_name => box_group_name($uri),
		name => $name,
		open => box_is_open($row),
		create_epoch => $row->[2] || 0,
		create_dt => epoch_to_iso8601($row->[2] || 0),
		expiration_epoch => $expiration,
		expiration_dt => $expiration ? epoch_to_iso8601($expiration) : '',
		creator => $creator_id,
		creator_name => $creator_name,
		creator_display => $creator_name ? "$creator_name ($creator_id)" : $creator_id,
		viewcount => $row->[5] || 0,
		greeting => $row->[7] || '',
		message => $row->[8] || '',
		tomailaddr => $row->[9] || '',
		expired => ($expiration > 0 && $expiration < $target_epoch) ? 1 : 0,
		unlimited => ($expiration <= 0) ? 1 : 0,
	};
}

sub box_group_name {
	my $uri = shift || '';
	if ($uri =~ m{^/[^/]+/Group Folder/([^/]+)}) {
		return $1;
	}
	return '';
}

sub final_path_segment {
	my $uri = shift || '';
	$uri =~ s/\/+$//;
	$uri =~ s/^.*\///;
	return $uri;
}

sub user_quota_item {
	my ($row, $total_used) = @_;
	my $used = $row->[3] || 0;
	return {
		userid => $row->[0] || '',
		name => $row->[1] || '',
		active => extstatus_on($row->[2] || '', 0, 'a') ? 1 : 0,
		used_bytes => $used,
		used_size => format_bytes($used),
		used_files => $row->[4] || 0,
		size_rate => ($total_used > 0) ? int(($used * 100 + ($total_used / 2)) / $total_used) : 0,
	};
}

sub inactive_user_quota_item {
	my $row = shift || [];
	my $used = $row->[2] || 0;
	return {
		userid => $row->[0] || '',
		name => $row->[1] || '',
		used_bytes => $used,
		used_size => format_bytes($used),
		used_files => $row->[3] || 0,
	};
}

sub file_item {
	my ($row, $target_epoch) = @_;
	my $modified = $row->[2] || 0;
	return {
		uri => $row->[0] || '',
		size_bytes => $row->[1] || 0,
		size => format_bytes($row->[1] || 0),
		lastmodified_epoch => $modified,
		lastmodified_dt => epoch_to_iso8601($modified),
		stale_days => $modified ? int(($target_epoch - $modified) / (24 * 3600)) : undef,
		lastmodifier => $row->[3] || '',
	};
}

sub extstatus_on {
	my ($extstatus, $index, $on_char) = @_;
	return 0 if !defined($extstatus) || length($extstatus) <= $index;
	return substr($extstatus, $index, 1) eq $on_char;
}

sub write_result {
	my ($location, $result_ref) = @_;
	my $dir = location_audit_dir($location);
	ensure_audit_dir($dir);

	my $tmp = File::Spec->catfile($dir, 'latest-file-audit.json.tmp.' . $$);
	my $out = File::Spec->catfile($dir, 'latest-file-audit.json');

	open(my $fh, '>', $tmp) or die "Failed to open $tmp: $!";
	print $fh to_json($result_ref);
	close($fh);
	set_audit_path_attrs($tmp, 0);
	rename($tmp, $out) or die "Failed to rename $tmp to $out: $!";
	set_audit_path_attrs($out, 0);
	my $err = File::Spec->catfile($dir, 'latest-file-audit.err');
	unlink($err) if -f $err;
}

sub write_error {
	my ($location, $message) = @_;
	my $dir = location_audit_dir($location || 'unknown');
	ensure_audit_dir($dir);
	my $err = File::Spec->catfile($dir, 'latest-file-audit.err');
	if (open(my $fh, '>', $err)) {
		print $fh iso8601_now() . ' ' . ($message || '') . "\n";
		close($fh);
		set_audit_path_attrs($err, 0);
	}
	writeLog("ERROR location=$location $message");
}

sub location_audit_dir {
	my $location = shift || 'unknown';
	$location =~ s/[^A-Za-z0-9_-]/_/g;
	return File::Spec->catdir($AUDIT_ROOT, $location);
}

sub issue_item {
	my ($level, $code, $message) = @_;
	return {
		level => $level || '',
		code => $code || '',
		message => $message || '',
	};
}

sub read_audit_config {
	my %conf;
	return %conf if !-f $CONFIG_FILE;
	if (open(my $fh, '<', $CONFIG_FILE)) {
		while (my $line = <$fh>) {
			chomp($line);
			next if $line =~ /^\s*#/ || $line =~ /^\s*$/;
			if ($line =~ /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*?)\s*$/) {
				$conf{$1} = $2;
			}
		}
		close($fh);
	}
	return %conf;
}

sub ensure_audit_config {
	ensure_audit_dir($AUDIT_ROOT);
	return if -f $CONFIG_FILE;
	if (open(my $fh, '>', $CONFIG_FILE)) {
		print $fh "# TeamFile file audit settings\n";
		print $fh "# hour/min define the daily audit execution time.\n";
		print $fh "hour=$DEFAULT_HOUR\n";
		print $fh "min=$DEFAULT_MIN\n";
		print $fh "# Optional fixed target date for testing: YYYY-MM-DD\n";
		print $fh "# target_date=\n";
		close($fh);
		set_audit_path_attrs($CONFIG_FILE, 0);
	}
}

sub ensure_audit_dir {
	my $dir = shift || '';
	return if !$dir;
	make_path($dir) if !-d $dir;
	set_audit_path_attrs($dir, 1);
}

sub set_audit_path_attrs {
	my ($path, $is_dir) = @_;
	return if !$path;
	my ($uid, $gid) = (getpwnam($AUDIT_OWNER))[2, 3];
	chown($uid, $gid, $path) if defined($uid) && defined($gid);
	chmod($is_dir ? 0750 : 0640, $path);
}

sub escape_sql_like {
	my $s = shift || '';
	$s =~ s/'/''/g;
	$s =~ s/\\/\\\\/g;
	$s =~ s/%/\\%/g;
	$s =~ s/_/\\_/g;
	return $s;
}

sub normalize_target_date {
	my $date = shift || '';
	if ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/) {
		return "$1-$2-$3";
	}
	if ($date =~ /^(\d{4})(\d{2})(\d{2})$/) {
		return "$1-$2-$3";
	}
	return yesterday_ymd();
}

sub yesterday_ymd {
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime(time - 24 * 3600);
	return sprintf('%04d-%02d-%02d', $year + 1900, $mon + 1, $mday);
}

sub ymd_to_epoch_end {
	my $date = shift || yesterday_ymd();
	my ($y, $m, $d) = ($date =~ /^(\d{4})-(\d{2})-(\d{2})$/);
	return timelocal(59, 59, 23, $d, $m - 1, $y - 1900);
}

sub epoch_to_iso8601 {
	my $epoch = shift || 0;
	return '' if !$epoch || $epoch !~ /^\d+$/;
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime($epoch);
	return sprintf('%04d-%02d-%02dT%02d:%02d:%02d', $year + 1900, $mon + 1, $mday, $hour, $min, $sec);
}

sub iso8601_now {
	my ($sec, $min, $hour, $mday, $mon, $year) = localtime(time);
	return sprintf('%04d-%02d-%02dT%02d:%02d:%02d', $year + 1900, $mon + 1, $mday, $hour, $min, $sec);
}

sub format_bytes {
	my $bytes = shift || 0;
	my @units = ('B', 'KB', 'MB', 'GB', 'TB');
	my $value = $bytes;
	my $unit = shift @units;
	while ($value >= 1024 && @units) {
		$value = $value / 1024;
		$unit = shift @units;
	}
	return ($unit eq 'B') ? sprintf('%d %s', $value, $unit) : sprintf('%.1f %s', $value, $unit);
}

sub to_json {
	my $v = shift;
	if (!defined($v)) { return 'null'; }
	if (ref($v) eq 'HASH') {
		return '{' . join(',', map { json_string($_) . ':' . to_json($v->{$_}) } sort keys %{$v}) . '}';
	}
	if (ref($v) eq 'ARRAY') {
		return '[' . join(',', map { to_json($_) } @{$v}) . ']';
	}
	if (!ref($v) && $v =~ /^-?\d+(?:\.\d+)?$/) { return $v; }
	return json_string($v);
}

sub json_string {
	my $s = shift;
	$s = '' if !defined($s);
	$s =~ s/\\/\\\\/g;
	$s =~ s/"/\\"/g;
	$s =~ s/\n/\\n/g;
	$s =~ s/\r/\\r/g;
	$s =~ s/\t/\\t/g;
	return '"' . $s . '"';
}

sub usage {
	print <<"_EOF_";
Usage: fileauditor [-v] [-f] [--hour H --min M] [--target-date YYYY-MM-DD] [--location NAME] | [-h]
       -v                  Show detail status
       -f                  Force execution regardless of configured time
       --hour H --min M    Scheduled execution time (default: 03:35)
       --target-date DATE  Audit target date. Default is yesterday.
       --location NAME     Audit only one location
       -h                  Show usage
_EOF_
}

sub printerr {
	my $msg = shift || '';
	print STDERR $msg if $opt_v && $msg;
}

sub initLog {
	make_path(getJobsWorkPath()) if !-d getJobsWorkPath();
	open $F_LOG, ">", $LOG_FILE;
}

sub writeLog {
	my $msg = shift || '';
	return if !$msg;
	my $ctime = localtime(time);
	print $F_LOG $ctime . ' : ' . $msg . "\n" if $F_LOG;
	printerr($msg . "\n");
}

sub closeLog {
	close $F_LOG if $F_LOG;
}
