#!/usr/bin/env arsh

# lite-weight file checker

#####################################
##     expose to target script     ##
#####################################

var cmd = ''
var self = ''

##############################
##     helper functions     ##
##############################

_usage() : Nothing {
    echo 1>&2 $@
    exit 2
}

_error() : Nothing {
    echo 1>&2 $@
    exit 1
}

function _error(ln : Int, m : String) : Nothing {
    if $self.empty() {
        _error $m
    } else {
        _error "$(basename $self):$ln: [error] $m"
    }
}

function _lines(path : String) : [String] {
    return "$(< $path)".split($'\n')
}

##############################
##     actual functions     ##
##############################

let RUN = "RUN"
let CHECK = "CHECK"
let CHECK_IF = "CHECK_IF"
let CHECK_RE = "CHECK_RE"
let CHECK_RE_IF = "CHECK_RE_IF"
let CHECKERR = "CHECKERR"
let CHECKERR_IF = "CHECKERR_IF"
let CHECKERR_RE = "CHECKERR_RE"
let CHECKERR_RE_IF = "CHECKERR_RE_IF"
let STATUS = "STATUS"
let REQUIRE = "REQUIRE"
let SHEBANG = "#!"

function _parse_num(v : String) : Int? {
    var r : Int?
    if let m = $/^ *(0|[1-9][0-9]*) *$/.match($v) {
        $r = $m.group(1)!.toInt()
    }
    return $r
}

type Directive(t : String, l : String, n : Int) {
    let type = $t   # directive type (RUN, CHECK, CHECKERR, STATUS, REQUIRES)
    assert !$l.empty()
    let line = case $type {
        "STATUS" => $_parse_num($l) ?? throw new Error("$STATUS directive must be positive number: \`$l'")
        $/CHECK.*_RE.*/ => new Regex($l, "")
        else => $l as Any
    }
    let ln = $n     # line number
}

function _parse_directives(path : String) : [String: [Directive]] {
    var map = [
        $RUN : new [Directive](),
        $REQUIRE : new [Directive](),
        $CHECK : new [Directive](),
        $CHECKERR : new [Directive](),
        $STATUS : new [Directive](),
    ]

    var lines = $_lines($path)
    if !$lines.empty() && $lines[0].startsWith($SHEBANG) {
        $map[$RUN].add(new Directive($SHEBANG, $lines[0], 1))
    }
    for(var i = 0; $i < $lines.size(); $i++) {
        var line = $lines[$i]
        var ln = $i + 1
        var matched = $/^# *(RUN|CHECK|CHECKERR|CHECK_IF|CHECKERR_IF|CHECK_RE|CHECKERR_RE|CHECK_RE_IF|CHECKERR_RE_IF|STATUS|REQUIRE):(.*)/.match($line)
        if let m = $matched {
            var type = $m.group(1)!
            $line = $m.group(2)!
            if !$line.startsWith(" ") {
                var prefix = $m.group(0)!
                if !$line.empty() {
                    $prefix = $prefix.slice(0, $prefix.lastIndexOf($line))
                }
                $_error($ln, "invalid directive format, must be start with \`$prefix '")
            }
            $line = $line.slice(1) # skip first ' '
            $line.ifEmpty() ?? $_error($ln, "invalid directive format, need non-emtpy string")

            var key = $type
            if $type == $CHECK || $type.startsWith("CHECK_") {
                $key = $CHECK
            }
            if $type == $CHECKERR || $type.startsWith("CHECKERR_") {
                $key = $CHECKERR
            }
            try {
                $map[$key].add(new Directive($type, $line, $ln))
            } catch e : Error {
                $_error($ln, $e.message())
            }
        }
    }

    ## check RUN directive or SHEBANG
    case $map[$RUN].size() {
        0 => $_error(1, "require $RUN directive or shebang")
        1 => if $map[$RUN][0].type == $SHEBANG {
                test -x $self || $_error(1, "must be executable: \`$self'")
                $map[$RUN].shift()
            } else {
                assert $map[$RUN][0].type == $RUN
            }
        else => {
            var runs = $map[$RUN]
            if $runs[0].type == $SHEBANG {
                $runs.shift()
                assert $runs[0].type == $RUN
            }
            $runs.size() == 1 || $_error($runs[1].ln, "$RUN directive has already defined")
        }
    }

    ## check STATUS directive
    if $map[$STATUS].size() > 1 {
        $_error($map[$STATUS][1].ln, "$STATUS directive has already defined")
    }
    return $map
}

# split check directives into chunks
function _to_chunk(checks : [Directive]) : [[Directive]] {
    var ret : [[Directive]]
    if !$checks[0].type.endsWith("_IF") && !$checks[0].type.endsWith("_RE") {
        $ret.add(new [Directive]())
    }

    for c in $checks {
        if $c.type.endsWith("_IF") || $c.type.endsWith("_RE") {
            $ret.add(new [Directive]())
        } elif !$ret.empty() && !$ret.peek().empty()  {
            var pp = $ret.peek().peek()
            if $pp.type.endsWith("_RE") || $pp.type.endsWith("_RE_IF") {
                $ret.add(new [Directive]())
            }
        }
        $ret.peek().add($c)
    }
    return $ret
}

function _match_chunk_re(checks : [Directive], output : [String]) : Bool {
    assert $checks.size() == 1 : "expect is 1, actual is ${$checks.size()}"

    var out = $output.join($'\n')
    var re = $checks[0].line as Regex
    if $re !~ $out {
        echo 1>&2 ${$checks[0].type} on line ${$checks[0].ln},
        echo 1>&2 "    Expect: $re"
        echo 1>&2 "    Actual: $out"
        return $false
    }
    return $true
}

# diff wrapper
var _diff_opts = ['diff', '-u']

# offset is base line number (1~)
function _diff(offset : Int, expect : String, actual : String) : (String, Bool) {
    assert $offset > 0

    $? = 0
    var o = "$(command $_diff_opts 2>&1 <(<<< $expect) <(<<< $actual))"
    var s = $? == 0

    if $s {
        return ($o, $s)
    }

    var out : [String]
    for line in $o.split($'\n') {
        if $line.startsWith('--- ') || $line.startsWith('+++ ') {
            continue    # skip first and second line
        }
        $out.add($line)
    }
    return ($out.join($'\n'), $s)
}

function _match_chunk(checks : [Directive], output : [String]) : Bool {
    $checks[0].line is Regex && return $_match_chunk_re($checks, $output)

    var eout = ""
    for c in $checks {
        if !$eout.empty() {
            $eout += $'\n'
        }
        $eout += $c.line
    }

    var r = $_diff($checks[0].ln, $eout, $output.join($'\n'))
    if !$r._1 {
        echo 1>&2 ${$checks[0].type} on line ${$checks[0].ln},
        echo 1>&2 ${r._0}
    }
    return $r._1
}

function _skip_until(c : Any, output : [String], index : Int) : Int {
    if let cc = $c as? String {
        for(; $index < $output.size(); $index++) {
            $output[$index] == $cc && break
        }
    } elif let cc = $c as? Regex {
        for(; $index < $output.size(); $index++) {
            $output[$index] =~ $cc && break
        }
    } else {
        assert $false : "unreachable"
    }
    return $index
}

function _check_output(checks : [Directive], output : [String]) : Bool {
    var index = 0
    var chunks = $_to_chunk($checks)
    var matched = 0
    for chunk in $chunks {
        if $chunk[0].type.endsWith("_IF") {
            $index = $_skip_until($chunk[0].line, $output, $index)
        }
        var limit = $index + $chunk.size()
        if $matched == $chunks.size() - 1 && $limit < $output.size() {
            $limit = $output.size() # match remain output
        }
        $_match_chunk($chunk, $output.slice($index, $limit)) || return $false
        $index = $limit
        $matched++
    }
    return $true
}

function _run_and_check(map : [String : [Directive]]) : Int {
    # resolve diff command
    if (command -v busybox &>> /dev/null) {
        $_diff_opts = ["busybox", "diff"]
    } else {
        $_diff_opts = ["diff", "-u"]
    }

    var run = case $map[$RUN].size() {
        0 => $MODULE._func("call $self")
        else => {
            var dd = $map[$RUN][0]
            var d = $dd.line as String
            try {
                $MODULE._func($d)
            } catch $e : Error {
                $_error($dd.ln, "invalid $RUN directive: \`$d', caused by" + $'\n' + $e.message())
            }
        }
    }

    assert command -v mktemp > /dev/null
    let outPath = "$(mktemp)".ifEmpty() ?? _error "mktemp failed"
    let errPath = "$(mktemp)".ifEmpty() ?? _error "mktemp failed"

    defer {
        rm -f $outPath
        rm -f $errPath
    }

    $? = 0
    var j = ($run() with 1> $outPath 2> $errPath) &
    var s = $j.wait()
    var outs = $_lines($outPath)
    var errs = $_lines($errPath)

    var ret = 0
    if !$map[$CHECK].empty() {
        if !$_check_output($map[$CHECK], $outs) { echo 1>&2 stdout is mismatched; $ret = 1; }
    }
    if !$map[$CHECKERR].empty() {
        if !$_check_output($map[$CHECKERR], $errs) { echo 1>&2 stderr is mismatched; $ret = 1; }
    }
    if !$map[$STATUS].empty() {
        var estatus = $map[$STATUS][0].line as Int
        $s == $estatus || _error "exit status is mismatched, expected: \`$estatus', actual: \`$s'"
    }
    return $ret
}

[<CLI(verbose: $true)>]
type Param() {
    [<Option(short: 'b', help: 'target executable file')>]
    var bin : String?

    [<Arg(required: $true, help: "target test script (must include litecheck directives)")>]
    var file = ""
}

# entry point
litecheck(p : Param) {
    # check file existence
    $self = (test -e ${p.file}) ? $p.file.realpath() : 
                    _usage ${$p.usage("file not found: \`${p.file}'")}
    test -f $self || _usage ${$p.usage("require regular file: ${p.file}")}

    # check bin
    if let b = $p.bin {
        $cmd = (test -e $b) ? $b.realpath() : _usage ${$p.usage("file not found: \`$b'")}
        (test -f $cmd && test -x $cmd) || _usage ${$p.usage("must be executable: \`$b'")}
    }

    # read directive
    var map = $_parse_directives($self)

    # check pre-condition
    for d in $map[$REQUIRE] {
        var cond = try { $MODULE._func($d.line as String)(); } 
                   catch e : Error { 
                       $_error($d.ln, "invalid $REQUIRE directive: \`${d.line}', caused by" + $'\n' + $e.message());
                   }
        $cond is Bool || $_error($d.ln, "$REQUIRE directive must be boolean expression: \`${d.line}'")
        $cond! as Bool || { echo skip "\`$self'"; exit 125; }
    }

    # run command
    return $_run_and_check($map)
}

shctl is-sourced || litecheck $@