package DataSerialization;

## DataSerialization.pm
## Shared code for dsEdit and dsMerge: YAML/JSON/INI format drivers, the INI reader and writer,
## format detection, and the atomic write with backup.
## Copyright (c) 2026, rodolico. All rights reserved.
## Licensed under the FreeBSD Simplified (2-Clause) License.
## See the project license text for full terms.
##
## Revision History:
##   2026-10-25  Claude     v1.0   Extracted from dsEdit v1.3 / dsMerge v1.0, which each carried a copy.

use strict;
use warnings;
use Exporter qw(import);
use File::Basename qw(dirname);
use File::Temp qw(tempfile);
use Scalar::Util qw(blessed);

our $VERSION = '1.0';
our @EXPORT_OK = qw(
    availableFormats formatSupported formatDriver normalizeFormatName detectFormatFromName
    setIniDelimiter lastIniWriteError writeAtomicFile trim
);

my %formatSupport = (
    yaml => 0,
    json => 0,
    ini  => 1,
);
my %formatDrivers = (
    yaml => undef,
    json => undef,
    ini  => undef,
);

sub initFormatDrivers {
    my %yamlModules = (
        'YAML::XS' => {
            load => sub { my ($input) = @_; return YAML::XS::Load($input); },
            dump => sub { my ($data) = @_; return YAML::XS::Dump($data); },
        },
        'YAML::PP' => {
            load => sub { my ($input) = @_; my $yaml = YAML::PP->new(); return $yaml->load_string($input); },
            dump => sub { my ($data) = @_; my $yaml = YAML::PP->new(); return $yaml->dump_string($data); },
        },
        'YAML::Syck' => {
            load => sub { my ($input) = @_; return YAML::Syck::Load($input); },
            dump => sub { my ($data) = @_; return YAML::Syck::Dump($data); },
        },
        'YAML::Tiny' => {
            load => sub { my ($input) = @_; return YAML::Tiny::Load($input); },
            dump => sub { my ($data) = @_; return YAML::Tiny::Dump($data); },
        },
    );

    my %jsonModules = (
        'JSON::PP' => {
            load => sub { my ($input) = @_; return JSON::PP::decode_json($input); },
            dump => sub { my ($data) = @_; return JSON::PP->new->canonical->pretty->encode($data); },
        },
        'JSON::XS' => {
            load => sub { my ($input) = @_; return JSON::XS::decode_json($input); },
            dump => sub { my ($data) = @_; return JSON::XS->new->canonical->pretty->encode($data); },
        },
        'Cpanel::JSON::XS' => {
            load => sub { my ($input) = @_; return Cpanel::JSON::XS::decode_json($input); },
            dump => sub { my ($data) = @_; return Cpanel::JSON::XS->new->canonical->pretty->encode($data); },
        },
        'JSON::MaybeXS' => {
            load => sub { my ($input) = @_; return JSON::MaybeXS::decode_json($input); },
            dump => sub { my ($data) = @_; return JSON::MaybeXS->new->canonical->pretty->encode($data); },
        },
    );

    for my $module (qw(YAML::XS YAML::PP YAML::Syck YAML::Tiny)) {
        next if !$yamlModules{$module};
        my $loaded = eval "require $module; 1";
        if (!$loaded) {
            next;
        }
        $formatDrivers{yaml} = $yamlModules{$module};
        $formatSupport{yaml} = 1;
        last;
    }

    for my $module (qw(JSON::PP JSON::XS Cpanel::JSON::XS JSON::MaybeXS)) {
        next if !$jsonModules{$module};
        my $loaded = eval "require $module; 1";
        if (!$loaded) {
            next;
        }
        $formatDrivers{json} = $jsonModules{$module};
        $formatSupport{json} = 1;
        last;
    }

    $formatDrivers{ini} = {
        load => sub { my ($input, $filename) = @_; return parseIniDocument($input, $filename || '(input)'); },
        dump => sub { my ($data) = @_; return dumpIniDocument($data); },
    };
}

initFormatDrivers();

my $lastIniWriteError = '';
my $iniDelimiter = '';   # '' = read ':' or '=', write '='; otherwise only that character

## Return the currently available serialization formats.
sub availableFormats {
    my @formats;
    push @formats, 'yaml' if $formatSupport{yaml};
    push @formats, 'json' if $formatSupport{json};
    push @formats, 'ini' if $formatSupport{ini};
    return @formats;
}

## Remove surrounding whitespace from a string.
sub trim {
    my ($value) = @_;
    $value = '' if !defined $value;
    $value =~ s/^\s+//;
    $value =~ s/\s+$//;
    return $value;
}

## Normalise a --format/--outformat value; returns '' if it is not a known format.
sub normalizeFormatName {
    my ($name) = @_;
    $name = lc(trim($name || ''));
    $name = 'yaml' if $name eq 'yml';
    return $name if $name =~ /\A(?:yaml|json|ini)\z/;
    return '';
}

## Build a dotted path string for diagnostics.
sub pathString {
    my ($partsRef) = @_;
    return '(root)' if !$partsRef || !@{$partsRef};
    return join('.', @{$partsRef});
}

## Parse one INI key/value line by first '=' or ':'.
sub parseIniKeyValueLine {
    my ($line, $filename, $lineNumber) = @_;
    my $eqPos = $iniDelimiter eq ':' ? -1 : index($line, '=');
    my $colonPos = $iniDelimiter eq '=' ? -1 : index($line, ':');
    my $delimiterPos = -1;

    if ($eqPos >= 0 && $colonPos >= 0) {
        $delimiterPos = ($eqPos < $colonPos) ? $eqPos : $colonPos;
    } elsif ($eqPos >= 0) {
        $delimiterPos = $eqPos;
    } elsif ($colonPos >= 0) {
        $delimiterPos = $colonPos;
    }

    if ($delimiterPos < 0) {
        die "Malformed INI line in $filename at line $lineNumber: missing key/value delimiter\n";
    }

    my $key = trim(substr($line, 0, $delimiterPos));
    my $value = trim(substr($line, $delimiterPos + 1));
    if ($key eq '') {
        die "Malformed INI line in $filename at line $lineNumber: empty key\n";
    }

    if ($value =~ /^"(.*)"\z/s || $value =~ /^'(.*)'\z/s) {
        $value = $1;
    }

    return ($key, $value);
}

## Parse an INI document to the common in-memory data structure.
sub parseIniDocument {
    my ($content, $filename) = @_;
    my %parsed;
    my $currentSection = '';
    my @lines = split /\n/, $content, -1;

    for (my $lineNumber = 1; $lineNumber <= @lines; $lineNumber++) {
        my $line = $lines[$lineNumber - 1];
        $line =~ s/\r\z//;

        if ($line =~ /^\s*\z/ || $line =~ /^\s*[#;]/) {
            next;
        }

        if ($line =~ /^\s*\[(.*)\]\s*\z/) {
            my $sectionName = trim($1);
            if ($sectionName eq '') {
                die "Malformed INI line in $filename at line $lineNumber: empty section name\n";
            }
            if (exists $parsed{$sectionName} && ref($parsed{$sectionName}) ne 'HASH') {
                die "Malformed INI line in $filename at line $lineNumber: section '$sectionName' has the same name as an earlier key\n";
            }
            $currentSection = $sectionName;
            $parsed{$sectionName} = {} if !exists $parsed{$sectionName};
            next;
        }

        if ($line =~ /^\s*\[/) {
            if (index($line, ']') >= 0) {
                die "Malformed INI line in $filename at line $lineNumber: text after ']' (inline comments are not supported)\n";
            }
            die "Malformed INI line in $filename at line $lineNumber: section header missing closing ']'\n";
        }

        my ($key, $value) = parseIniKeyValueLine($line, $filename, $lineNumber);
        if ($currentSection eq '') {
            $parsed{$key} = $value;
        } else {
            if (!exists $parsed{$currentSection} || ref($parsed{$currentSection}) ne 'HASH') {
                $parsed{$currentSection} = {};
            }
            $parsed{$currentSection}{$key} = $value;
        }
    }

    return \%parsed;
}

## Convert an INI scalar/list value to plain output text.
sub iniValueToText {
    my ($value) = @_;
    return '' if !defined $value;
    if (ref($value) eq 'ARRAY') {
        my @items;
        for my $item (@{$value}) {
            push @items, defined $item ? "$item" : '';
        }
        return join(',', @items);
    }
    return "$value";
}

## Validate whether a key or section name is writable in INI format.
sub iniNameIsUnsafe {
    my ($name) = @_;
    return 1 if !defined $name;
    return 1 if $name =~ /[.=]/;
    return 1 if $name =~ /\n/;
    return 0;
}

## A key must not contain the delimiter(s) the reader would split on.
sub iniKeyIsUnsafe {
    my ($name) = @_;
    return 1 if iniNameIsUnsafe($name);
    return 1 if $iniDelimiter ne ':' && index($name, '=') >= 0;
    return 1 if $iniDelimiter ne '=' && index($name, ':') >= 0;
    return 0;
}

## Validate an INI-compatible value and return an error path if invalid.
sub validateIniValue {
    my ($value, $pathRef) = @_;
    my $refType = ref($value);
    return '' if !$refType || $refType eq '';
    return '' if blessed($value) && $refType ne 'HASH' && $refType ne 'ARRAY';   # JSON/YAML booleans

    if ($refType eq 'ARRAY') {
        for (my $i = 0; $i < @{$value}; $i++) {
            my $element = $value->[$i];
            my $elementRef = ref($element);
            my @elementPath = (@{$pathRef}, $i);
            if ($elementRef eq 'HASH' || $elementRef eq 'ARRAY') {
                return pathString(\@elementPath);
            }
            if (defined $element && index($element, ',') >= 0) {
                return pathString(\@elementPath);
            }
        }
        return '';
    }

    if ($refType eq 'HASH') {
        return pathString($pathRef);
    }

    return pathString($pathRef);
}

## Validate the full structure before writing as INI.
sub validateStructureForIni {
    my ($data) = @_;
    if (ref($data) ne 'HASH') {
        return '(root)';
    }

    for my $topKey (sort keys %{$data}) {
        if (iniNameIsUnsafe($topKey)) {
            return $topKey;
        }

        my $topValue = $data->{$topKey};
        my $topRef = ref($topValue);
        if ($topRef ne 'HASH' && iniKeyIsUnsafe($topKey)) {
            return $topKey;
        }
        if ($topRef eq 'HASH') {
            for my $sectionKey (sort keys %{$topValue}) {
                if (iniKeyIsUnsafe($sectionKey)) {
                    return pathString([$topKey, $sectionKey]);
                }
                my $invalidPath = validateIniValue($topValue->{$sectionKey}, [$topKey, $sectionKey]);
                return $invalidPath if $invalidPath ne '';
            }
            next;
        }

        my $invalidPath = validateIniValue($topValue, [$topKey]);
        return $invalidPath if $invalidPath ne '';
    }

    return '';
}

## Serialize the in-memory structure to deterministic INI text.
sub dumpIniDocument {
    my ($data) = @_;
    $lastIniWriteError = '';
    my $invalidPath = validateStructureForIni($data);
    if ($invalidPath ne '') {
        $lastIniWriteError = "Cannot write INI because the structure is deeper than one level or contains an unsafe name at '$invalidPath'.";
        return undef;
    }

    my @globalKeys = sort grep { ref($data->{$_}) ne 'HASH' } keys %{$data};
    my @sectionNames = sort grep { ref($data->{$_}) eq 'HASH' } keys %{$data};
    my @lines;

    my $writeDelimiter = $iniDelimiter eq ':' ? ': ' : ' = ';
    for my $key (@globalKeys) {
        push @lines, $key . $writeDelimiter . iniValueToText($data->{$key});
    }

    push @lines, '' if @globalKeys && @sectionNames;

    for (my $i = 0; $i < @sectionNames; $i++) {
        my $sectionName = $sectionNames[$i];
        my $section = $data->{$sectionName};
        push @lines, "[$sectionName]";
        for my $key (sort keys %{$section}) {
            push @lines, $key . $writeDelimiter . iniValueToText($section->{$key});
        }
        push @lines, '' if $i < @sectionNames - 1;
    }

    return join("\n", @lines) . "\n";
}
## Detect the file format from its extension.
sub detectFormatFromName {
    my ($filename) = @_;
    return '' if !defined $filename || $filename eq '';
    my $lower = lc($filename);
    return 'json' if $lower =~ /\.json\z/;
    return 'yaml' if $lower =~ /\.(?:yaml|yml)\z/;
    return 'ini' if $lower =~ /\.ini\z/;
    return '';
}
## Build a backup path that avoids collisions with previous backups.
sub backupPathFor {
    my ($target) = @_;
    my $backup = $target . '.bak';
    my $index = 1;
    while (-e $backup) {
        $backup = $target . ".bak.$index";
        $index++;
    }
    return $backup;
}

## Write content to disk atomically while preserving a backup first.
sub writeAtomicFile {
    my ($target, $content) = @_;
    my $dir = dirname($target);
    my ($fh, $tempPath) = tempfile('dsTmpXXXXXX', DIR => $dir, UNLINK => 0);
    print {$fh} $content or do {
        print STDERR "Unable to write temporary file for $target: $!\n";
        close $fh;
        unlink $tempPath if -e $tempPath;
        return 0;
    };
    close $fh or do {
        print STDERR "Unable to close temporary file for $target: $!\n";
        unlink $tempPath if -e $tempPath;
        return 0;
    };

    if (-e $target) {
        my $backup = backupPathFor($target);
        rename($target, $backup) or do {
            print STDERR "Unable to preserve backup for $target: $!\n";
            unlink $tempPath if -e $tempPath;
            return 0;
        };
    }

    rename($tempPath, $target) or do {
        print STDERR "Unable to move $tempPath to $target: $!\n";
        unlink $tempPath if -e $tempPath;
        return 0;
    };

    return 1;
}

## True if the format's Perl module is available.
sub formatSupported {
    my ($format) = @_;
    return $formatSupport{$format || ''} ? 1 : 0;
}

## The { load => sub, dump => sub } driver for a format, or undef.
sub formatDriver {
    my ($format) = @_;
    return $formatDrivers{$format || ''};
}

## Set the INI key/value delimiter: ':' or '=' to use only that one, '' for read either / write '='.
sub setIniDelimiter {
    my ($delimiter) = @_;
    $iniDelimiter = defined $delimiter ? $delimiter : '';
    return $iniDelimiter;
}

## Why the last INI write was refused ('' if it was not).
sub lastIniWriteError {
    return $lastIniWriteError;
}

1;
