#!/usr/bin/env perl
use strict;
use warnings;
use Test::More;
use File::Temp qw(tempdir);
use File::Slurp qw(read_file write_file);

use File::SOPS;
use File::SOPS::Metadata;
use lib 't/lib';
use SopsBin qw(find_sops_bin);

# ----------------------------------------------------------------------------
# k174 / docs/adr/0052 -- the DIAGNOSIS half of the three tickets.
#
# The read direction was closed by t/67: sops writes a Go display form where
# an unencrypted slot's MAC should hold the typed value, and both
# implementations refuse the document. The refusal here is loud and correct.
# What it could not do was point the user at the cause: the same bytes appear
# in a legitimate string sops reads at exit 0, so a writer cannot infer from
# the failure whether the file was tampered with, generated by another tool,
# or was made by sops but cannot now be read by sops either.
#
# THIS FILE pins the hedge. The hint appended to the croak says the failure
# is consistent with a sops-written-and-sops-refuses-it document, names the
# display form and the path, and never claims the cause outright. Two legs.
#
# Section 1 is Perl-only and proves the negative: the hint stays silent on
# a YAML/JSON tree, on a plain string, on an ENCRYPTED slot, and on a format
# class with no format_name -- i.e., it does not spread into the four places
# it would misdiagnose. Section 2 drives the sops binary end-to-end and
# proves the positive: the hint fires on the exact wire forms the binary
# writes for a typed value in an untyped store, names them, and uses the
# hedged wording over the unhedged one. Everything from 2 on skips without
# the binary, and then this file proves nothing about sops.
# ----------------------------------------------------------------------------

my $sops_bin = find_sops_bin();
diag("Using sops binary: $sops_bin") if $sops_bin;

sub exception {
    my ($code) = @_;
    my $ok = eval { $code->(); 1 };
    return $ok ? undef : $@;
}

###############################################################################
# 1. The hint stays silent where the diagnosis would mislead.
###############################################################################

# Each row: a label, args to _mac_failure_sops_display_hint, the expected
# return (text or empty string), and what asserts it. The walker is reached
# directly so this section has no sops dependency.

subtest 'the hint stays silent on YAML/JSON, plain strings, encrypted slots' => sub {
    my $meta = File::SOPS::Metadata->new(unencrypted_suffix => '_unencrypted');

    # No format class: the diagnostic can never know if the store is untyped.
    is(File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => 'true' }, $meta, undef),
        '',
        'no format_class: the hint does not fire');

    # YAML format: the wire form is typed, the digest covers the typed value,
    # the read returns a bool -- they agree, so this only fires in a flagged
    # doc, and it stays silent on a healthy YAML.
    is(File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => 'true' }, $meta, 'File::SOPS::Format::YAML'),
        '',
        'YAML format: the hint does not fire');

    # JSON format: every wire form is typed in JSON, same story as YAML.
    is(File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => '1.0' }, $meta, 'File::SOPS::Format::JSON'),
        '',
        'JSON format: the hint does not fire');

    # ENV format, but the leaf text is not a display form sops writes.
    is(File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => 'hello-world' }, $meta, 'File::SOPS::Format::ENV'),
        '',
        'env format, plain string: the hint does not fire');

    # ENV format, but the path is an ENCRYPTED slot. The walker filters the
    # encrypted slot; an ENC[...] blob there is the digest's text, not its
    # typed value, so the rule below cannot apply.
    is(File::SOPS::_mac_failure_sops_display_hint(
        { secret => 'true' }, $meta, 'File::SOPS::Format::ENV'),
        '',
        'env format, encrypted slot: the hint does not fire');

    # Format class with no format_name method -- the mock handlers in t/51.
    # The hint must not change behaviour for those tests.
    my $no_name = bless {}, 'NoNameFormat';
    is(File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => 'true' }, $meta, $no_name),
        '',
        'format_class without format_name: the hint does not fire');

    # Happy path: the same tree that would fail MAC in section 2 produces a
    # HINT here, naming the path and the displayed text.
    my $hint = File::SOPS::_mac_failure_sops_display_hint(
        { v_unencrypted => 'true' }, $meta, 'File::SOPS::Format::ENV');
    like($hint, qr/\bv_unencrypted\b/,
        'happy path: the hint names the leaf path');
    like($hint, qr/\btrue\b/,
        'happy path: the hint names the leaf text');
    like($hint, qr/\benv\b/i,
        'happy path: the hint names the format');
};

if (!$sops_bin) {
    diag('sops binary not found -- sections 2 and on prove nothing about '
       . 'sops, and they did not run');
    done_testing();
    exit 0;
}

require Crypt::Age;
my $tempdir = tempdir(CLEANUP => 1);
my ($public, $secret) = Crypt::Age->generate_keypair();
write_file("$tempdir/key.txt", $secret);
$ENV{SOPS_AGE_KEY_FILE} = "$tempdir/key.txt";

# sops writes the file. opts:
#   --input-type yaml  -- the source is plain YAML, the test owns the
#                        source spelling to keep the corpus small
#   --output-type dotenv|ini  -- the untyped store the brief is about
sub sops_write {
    my ($fmt, $yaml) = @_;
    write_file("$tempdir/src.yaml", $yaml);
    my $out = `$sops_bin -e --input-type yaml --output-type $fmt --age $public $tempdir/src.yaml 2>&1`;
    return ($? >> 8, $out);
}

# dotenv is one level, INI is exactly two levels (docs/adr/0047).
sub yaml_for {
    my ($fmt, $key, $literal) = @_;
    return $fmt eq 'ini' ? "db:\n  $key: $literal\n" : "$key: $literal\n";
}

# Display forms sops's Go printer writes, taken straight from the detection
# rule in docs/adr/0052 -- one row per regex family in
# _is_sops_display_form_text.
my @FORMS = (
    [ 'boolean true',  'true'  ],
    [ 'boolean false', 'false' ],
    [ 'null',          'null'  ],
    [ 'integral float','1.0'   ],
    [ 'exponent float','1e20'  ],
);

for my $fmt (qw(dotenv ini)) {

    ###########################################################################
    # 2. sops writes a display form, this library refuses it, the hint fires.
    ###########################################################################
    subtest "$fmt: hint fires when MAC fails on a sops-written display form" => sub {
        for my $row (@FORMS) {
            my ($name, $typed) = @$row;

            my ($src_erc, $document) = sops_write(
                $fmt, yaml_for($fmt, 'v_unencrypted', $typed));
            die "sops -e failed for $name: $document"
                unless $src_erc == 0;

            my $err = exception(sub {
                File::SOPS->decrypt(
                    encrypted  => $document,
                    identities => [$secret],
                    format     => $fmt eq 'dotenv' ? 'env' : 'ini',
                );
            });
            ok(defined $err, "$name: MAC verification refuses the document");

            like($err, qr/MAC verification failed/,
                "$name: the existing MAC message stays in place");
            like($err, qr/display form/,
                "$name: the hint names the display-form class");
            like($err, qr/`sops -d` rejects/,
                "$name: the hint names the second leg -- sops refuses it too");
            like($err, qr/consistent (?:cause|with)/,
                "$name: the hint is hedged, never a confirmed cause");
            like($err, qr/v_unencrypted/,
                "$name: the hint names the unencrypted leaf path");
        }
    };

    ###########################################################################
    # 3. Real tampering in an unencrypted slot also fires the hint, and the
    #    hint reports the SHAPE of the file (a Go display form in a typed-value
    #    slot) rather than the spelling. The hedge keeps the message honest:
    #    a tampered file isn't necessarily sops-written, so the wording has to
    #    stay "consistent", not "this is".
    ###########################################################################
    subtest "$fmt: hint hedges on a tampered document" => sub {
        # Take a working sops document with a display form in v_unencrypted,
        # swap the unencrypted text for a DIFFERENT display form -- a real
        # tampering where the new text is itself a Go display form, so the
        # walker still has something to match against.
        my ($src_erc, $document) = sops_write(
            $fmt, yaml_for($fmt, 'v_unencrypted', 'true'));
        die "sops -e failed: $document"
            unless $src_erc == 0;

        # dotenv: v_unencrypted=true, ini: v_unencrypted = true. The
        # substitution captures the separator so it survives both.
        my $tampered = $document;
        $tampered =~ s/(v_unencrypted\s*=\s*)true/${1}false/;

        my $err = exception(sub {
            File::SOPS->decrypt(
                encrypted  => $tampered,
                identities => [$secret],
                format     => $fmt eq 'dotenv' ? 'env' : 'ini',
            );
        });
        ok(defined $err, 'a tampered display form is refused too');

        like($err, qr/consistent (?:cause|with)/,
            'hedge preserved on a tampered document');
        like($err, qr/\bfalse\b/,
            "the hint reports the SHAPE of the leaf, naming what's there now");
        unlike($err, qr/\bthis is the cause\b|\bthe cause is\b|\bexactly the cause\b/,
            'the hint never over-claims on a tampered document');
    };

    ###########################################################################
    # 4. A sops-written plaintext with NO display form in an unencrypted slot
    #    verifies cleanly: the digest agrees with what the read returns, the
    #    croak is not reached, the hint does not fire (because there is no
    #    croak to be appended to). A passing read here is the negative leg
    #    of the diagnosis gap.
    ###########################################################################
    subtest "$fmt: a sops-written plaintext with no display form verifies" => sub {
        my ($src_erc, $document) = sops_write(
            $fmt, yaml_for($fmt, 'v_unencrypted', 'plain-string'));
        die "sops -e failed: $document"
            unless $src_erc == 0;

        my $data = File::SOPS->decrypt(
            encrypted  => $document,
            identities => [$secret],
            format     => $fmt eq 'dotenv' ? 'env' : 'ini',
        );
        my $leaf = $fmt eq 'ini'
            ? $data->{db}{v_unencrypted}
            : $data->{v_unencrypted};
        is($leaf, 'plain-string',
            'plain-text unencrypted slot reads as the string sops wrote');
    };
}

done_testing();
