### ----------------------------------------------------
### -- PDF
### -- Licenses: Artistic-2.0
### -- Authors: David Warring
### -- File: lib/PDF.rakumod
### ----------------------------------------------------
#| this class represents the top level node in a PDF or FDF document,
#| the trailer dictionary
unit class PDF:ver<0.6.11>;

use PDF::COS::Dict;
also is PDF::COS::Dict;

use PDF::COS;
use PDF::IO::Serializer;
use PDF::IO::Crypt::PDF;
use PDF::IO::Reader;
use PDF::IO::Writer;
use PDF::COS::Tie;
use JSON::Fast;

# use ISO_32000::Table_15-Entries_in_the_file_trailer_dictionary;
# also does ISO_32000::Table_15-Entries_in_the_file_trailer_dictionary;

has Int $.Size is entry;                              # (Required; shall not be an indirect reference) greater than the highest object number defined in the file.

use PDF::COS::Type::Encrypt;
has PDF::COS::Type::Encrypt $.Encrypt is entry;       # (Required if document is encrypted; PDF 1.1) The document’s encryption dictionary

use PDF::COS::Type::Info;
has PDF::COS::Type::Info $.Info is entry(:indirect);  # (Optional; must be an indirect reference) The document’s information dictionary
has Str $.id;
has Str @.ID is entry(:len(2));                       # (Required if an Encrypt entry is present; optional otherwise; PDF 1.1) An array
                                                      # of two byte-strings constituting a file identifier

has Hash $.Root is entry(:indirect);                  # generic document root, as defined by subclassee, e.g.  PDF::Class, FDF
has PDF::IO::Crypt::PDF $.crypt is rw;
has $!flush = False;

has UInt $.Prev is entry; 

submethod TWEAK(:$file, |c) is hidden-from-backtrace {
    given PDF::COS.loader.?target-class -> $target-class {
        die "Unable to mix {self.WHAT.raku} with {$target-class}"
            unless $target-class === Nil || self.isa($target-class);
    }
    self!open-file($_, |c) with $file;
}

method id is rw {
    $!id //= do {
        # [PDF 32000-2 Section 14.4 File Identifiers] recommends computing the ID "using a
        # message digest algorithm such as MD5" ... "using the following information
        # • The current time;
        # • A string representation of the PDF file’s location;
        # • The size of the PDF file in bytes."
        #
        # Contrary to this, just generate a random identifier.

        my Str $hex-string = Buf.new((^256).pick xx 16).decode("latin-1");
        PDF::COS.coerce: :$hex-string;
    }
}

#| open the input file-name or path
method open($spec, |c) is hidden-from-backtrace {
    self.new!open-file: $spec, |c;
}
method !open-file(::?CLASS:D: $spec, Str :$type, |c) is hidden-from-backtrace {
    my PDF::IO::Reader $reader .= new: :trailer(self);
    self.reader = $reader;
    $reader.open($spec, |c);
    with $type {
        die "PDF file has wrong type: " ~ $reader.type
            unless $reader.type eq $_;
    }
    $!crypt = $_
        with $reader.crypt;
    self;
}

method encrypt(PDF:D $doc: Str :$owner-pass!, Str :$user-pass = '', Bool :$EncryptMetadata = True, |c ) {

    with $.reader {
        with .crypt {
            # the input document is already encrypted
            die "PDF is already encrypted. Need to be owner to re-encrypt"
                unless .is-owner;
        }
    }

    $doc<Encrypt>:delete;
    $!flush = True;
    $!crypt = PDF::COS.required('PDF::IO::Crypt::PDF').new: :$doc, :$owner-pass, :$user-pass, :$EncryptMetadata, |c;
}

method !is-indexed {
    with $.reader {
        ? (.input && .xrefs && .xrefs[0]);
    }
    else {
        False;
    }
}

method cb-finish {
    self.?cb-init
        unless self<Root>:exists;
    self<Root>.?cb-finish;
}
#| perform an incremental save back to the opened input file, or write
#| differences to the specified file
method update(IO::Handle :$diffs, |c) is hidden-from-backtrace {

    die "Newly encrypted PDF must be saved in full"
        if $!flush;

    die "PDF has not been opened for indexed read."
        unless self!is-indexed;

    self.cb-finish;

    my $type = $.reader.type;
    self!set-id: :$type ;

    my PDF::IO::Serializer $serializer .= new: :$.reader, :$type;
    my Array $body = $serializer.body( :updates, |c );
    .crypt-ast('body', $body, :mode<encrypt>)
        with $!crypt;

    if $diffs && $diffs.path ~~ m:i/'.json' $/ {
        # JSON output to a separate diffs file.
        my %ast = :cos{ :$body };
        $diffs.print: to-json(%ast);
        $diffs.close;
    }
    elsif +$body[0]<objects> {
        my IO::Handle $fh;
        my Bool $in-place = False;

        do with $diffs {
            # Seperate saving of updates
            $fh = $_ unless .path ~~ $.reader.file-name;

        }
        $fh //= do {
            $in-place = True;
            # Append update to the input PDF
            given $.reader.file-name {
                die "Incremental update of JSON files is not supported"
                    if  m:i/'.json' $/;
                .IO.open(:a, :bin);
            }
        }

        self!incremental-save($fh, $body[0], :$diffs, :$in-place);
    }
}

method !incremental-save(IO::Handle:D $fh, Hash $body, :$diffs, :$in-place) is hidden-from-backtrace {
    my constant Pad = "\n\n".encode('latin-1');

    my Hash $trailer = $body<trailer><dict>;
    my UInt $prev = $trailer<Prev>;
    my UInt $size = $.reader.size;
    my $compat = $.reader.compat;
    my PDF::IO::Writer $writer .= new: :$prev, :$size, :$compat;
    my $offset = $.reader.input.codes + Pad.bytes;

    $fh.write: Pad;
    $writer.stream-body: $fh, $body, my @entries, :$offset;
    $fh.close;

    if $in-place {
        # Input PDF updated; merge the updated entries in the index
        $prev = $writer.prev;
        $size = $writer.size;
        $.reader.update-index: :@entries, :$prev, :$size;
        $.Size = $size;
    }
}

method ast(|c) {
    self.cb-finish;
    my $type = $.reader.?type
        // self.?type
        // (self<Root><FDF>.defined ?? 'FDF' !! 'PDF');

    self!set-id( :$type );
    my PDF::IO::Serializer $serializer .= new;
    $serializer.ast: self, :$type, :$!crypt, |c;
}

method !ast-writer(|c) {
    my $eager := ! $!flush;
    my $ast = $.ast: :$eager, |c;
    PDF::IO::Writer.new: :$ast;
}

multi method save-as(IO::Handle $ioh, |c) is hidden-from-backtrace {
    self!ast-writer(|c).stream-cos: $ioh;
}

multi method save-as(IO() $iop,
                 Bool :$preserve = True,
                 Bool :$rebuild = False,
                 Bool :$stream,
                 |c) {
    when $iop.extension.lc eq 'json' {
        # save as JSON
        $iop.spurt: to-json( $.ast: |c );
    }
    when $preserve && !$rebuild && !$!flush && self!is-indexed && $.reader.file-name.defined {
        # copy the input PDF, then incrementally update it. This is faster
        # and plays better with digitally signed documents.
        my $diffs = $iop.open(:a, :bin);
        given $.reader.file-name {
            .IO.copy: $iop
                unless $iop.path eq $_;
        }
        $.update: :$diffs, |c;
    }
    default {
        # full save
        if $stream {
            # wont work for in-place update
            my $ioh = $iop.open(:w, :bin);
            self!ast-writer(|c).stream-cos($ioh);
            $ioh.close;
        }
        else {
            $iop.spurt: self!ast-writer(|c).Blob;
        }
    }
}

#| stringify to the serialized PDF
method Str(|c) {
    self!ast-writer(|c).write;
}

# permissions check, e.g: $doc.permitted( PermissionsFlag::Modify )
method permitted(UInt $flag --> Bool) is DEPRECATED('please use PDF::Class.permitted') {

    return True
        if $!crypt.?is-owner;

    with self.Encrypt {
        .permitted($flag);
    }
    else {
        True;
    }
}

method Blob(|c) returns Blob {
    self.Str(|c).encode: "latin-1";
}

#| Initialize or update the document id
method !set-id(Str :$type) {
    my $obj = $type ~~ 'FDF' ?? self<Root><FDF> !! self;
    with $obj<ID> {
        .[1] = $.id; # Update modification ID
    }
    else {
        $_ = [ $.id xx 2 ]; # Initialize creation and modification IDs
    }
    $!id = Nil;
}


