//! # kobold-guard
//!
//! Operational corruption detection for fixed-record COBOL data. Migrations break in **transfer**,
//! before a single field is parsed: a fixed-length file moved in FTP ASCII mode gains CR/LF pairs,
//! an EBCDIC dataset round-tripped through a code-page-naive tool has its zoned signs mangled, a
//! truncated transfer loses trailing bytes, and a packed-decimal field arrives with non-BCD nibbles.
//! kobold-guard triages a byte image against a declared fixed-record layout and emits a fail-closed
//! findings report -- evidence of *where the damage is*, not a parse.
//!
//! Part of the KOBOLD ecosystem -- independently-authored forensic tooling, Apache-2.0. This crate
//! contains **no GnuCOBOL/libcob source** and depends on nothing; the byte formats it inspects
//! (packed-decimal, zoned-decimal, fixed records) are public, long-documented data conventions.
//!
//! The checks are deliberately conservative: a finding means "this byte image is inconsistent with
//! the declared layout", never "this value is wrong". Clean data produces zero findings.
#![forbid(unsafe_code)]

use serde::{Deserialize, Serialize};

/// How a fixed-width field is physically encoded. Only the encodings whose *byte shape* can be
/// validated independently of a value are modelled; opaque alphanumeric is checked only for
/// transfer artifacts.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Encoding {
    /// `PIC X(n)` -- opaque text; only transfer-artifact scanning applies.
    Alphanumeric,
    /// `PIC 9(n) [DISPLAY]` ASCII zoned: each byte `0x30..=0x39`, optional trailing overpunch sign.
    ZonedAscii { signed: bool },
    /// `PIC 9(n) DISPLAY` EBCDIC zoned: zone nibble `0xF` (unsigned) / `0xC`,`0xD` (sign byte).
    ZonedEbcdic { signed: bool },
    /// `PIC S9(n) COMP-3` packed decimal: BCD nibbles + a trailing sign nibble.
    Packed { digits: u32, signed: bool },
}

/// A field within the fixed record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Field {
    pub name: String,
    /// 0-based byte offset within the record.
    pub offset: usize,
    /// Physical byte width.
    pub len: usize,
    pub encoding: Encoding,
}

/// A fixed-length record layout: the logical record length and its fields.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Layout {
    /// Logical record length (LRECL) in bytes.
    pub lrecl: usize,
    #[serde(default)]
    pub fields: Vec<Field>,
}

/// Severity of a finding. `Damage` = the bytes cannot be a faithful instance of the layout;
/// `Warn` = suspicious but tolerable on some systems; `Info` = observation worth recording.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Severity {
    Info,
    Warn,
    Damage,
}

/// A stable rule identifier -- the *kind* of corruption observed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum Rule {
    /// File length is not a whole multiple of LRECL -> trailing-byte loss / truncated transfer.
    RecordLengthAnomaly,
    /// CR (0x0D) or LF (0x0A) injected inside a fixed record -> FTP ASCII-mode corruption.
    CrlfInjection,
    /// Trailing 0x1A (SUB / DOS soft-EOF) inside record bytes.
    SubEofByte,
    /// A packed-decimal digit nibble is not 0-9.
    PackedBadDigitNibble,
    /// A packed-decimal sign nibble is not a recognised sign (A,B,C,D,E,F).
    PackedBadSignNibble,
    /// A packed field's byte width disagrees with its declared digit count.
    PackedWidthMismatch,
    /// A zoned-decimal byte is outside the valid digit/zone/overpunch set.
    ZonedBadByte,
}

/// One located finding.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Finding {
    pub rule: Rule,
    pub severity: Severity,
    /// 0-based record index (`usize::MAX` for whole-file findings).
    pub record: usize,
    /// Field name, when the finding is field-local.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub field: Option<String>,
    /// Absolute byte offset within the image, when applicable.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub offset: Option<usize>,
    pub detail: String,
}

/// The aggregate triage report.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Report {
    pub lrecl: usize,
    pub total_bytes: usize,
    pub whole_records: usize,
    /// Leftover bytes after the last whole record (non-zero => RecordLengthAnomaly).
    pub trailing_bytes: usize,
    pub findings: Vec<Finding>,
    pub damage: usize,
    pub warn: usize,
    pub info: usize,
    /// `true` when no `Damage`-severity finding was raised.
    pub clean: bool,
}

/// Sentinel record index for findings that apply to the whole image rather than one record.
pub const WHOLE_FILE: usize = usize::MAX;

/// Validate a single COMP-3 packed-decimal field. `digits` is the PIC 9-count.
///
/// Layout: `digits / 2 + 1` bytes. Every nibble is a BCD digit 0-9 except the low nibble of
/// the final byte, which is the sign. When `digits` is even the leading high nibble is unused and
/// conventionally 0. Recognised sign nibbles: `0xC`/`0xF` positive, `0xD` negative; `0xA`,`0xB`,`0xE`
/// are accepted by some runtimes (Warn) and anything else is damage.
pub fn check_packed(bytes: &[u8], digits: u32, record: usize, field: &str, offset: usize) -> Vec<Finding> {
    let mut out = Vec::new();
    let expected = (digits / 2 + 1) as usize;
    if bytes.len() != expected {
        out.push(Finding {
            rule: Rule::PackedWidthMismatch,
            severity: Severity::Damage,
            record,
            field: Some(field.to_string()),
            offset: Some(offset),
            detail: format!("declared {digits} digits => {expected} bytes, found {}", bytes.len()),
        });
        return out;
    }
    let last = bytes.len() - 1;
    for (i, &b) in bytes.iter().enumerate() {
        let hi = b >> 4;
        let lo = b & 0x0F;
        if hi > 9 {
            out.push(Finding {
                rule: Rule::PackedBadDigitNibble,
                severity: Severity::Damage,
                record,
                field: Some(field.to_string()),
                offset: Some(offset + i),
                detail: format!("byte {i} high nibble {hi:#x} is not a digit"),
            });
        }
        if i == last {
            // low nibble is the sign
            match lo {
                0xC | 0xD | 0xF => {}
                0xA | 0xB | 0xE => out.push(Finding {
                    rule: Rule::PackedBadSignNibble,
                    severity: Severity::Warn,
                    record,
                    field: Some(field.to_string()),
                    offset: Some(offset + i),
                    detail: format!("uncommon sign nibble {lo:#x} (accepted by some runtimes)"),
                }),
                _ => out.push(Finding {
                    rule: Rule::PackedBadSignNibble,
                    severity: Severity::Damage,
                    record,
                    field: Some(field.to_string()),
                    offset: Some(offset + i),
                    detail: format!("sign nibble {lo:#x} is not a valid sign"),
                }),
            }
        } else if lo > 9 {
            out.push(Finding {
                rule: Rule::PackedBadDigitNibble,
                severity: Severity::Damage,
                record,
                field: Some(field.to_string()),
                offset: Some(offset + i),
                detail: format!("byte {i} low nibble {lo:#x} is not a digit"),
            });
        }
    }
    out
}

/// Validate an ASCII zoned-decimal field: every byte a digit `0x30..=0x39`, with an optional
/// trailing overpunch sign on the last byte (GnuCOBOL ASCII scheme: positive `0x30..=0x39`,
/// negative `0x70..=0x79`).
pub fn check_zoned_ascii(bytes: &[u8], signed: bool, record: usize, field: &str, offset: usize) -> Vec<Finding> {
    let mut out = Vec::new();
    if bytes.is_empty() {
        return out;
    }
    let last = bytes.len() - 1;
    for (i, &b) in bytes.iter().enumerate() {
        let ok = if signed && i == last {
            (0x30..=0x39).contains(&b) || (0x70..=0x79).contains(&b)
        } else {
            (0x30..=0x39).contains(&b)
        };
        if !ok {
            out.push(Finding {
                rule: Rule::ZonedBadByte,
                severity: Severity::Damage,
                record,
                field: Some(field.to_string()),
                offset: Some(offset + i),
                detail: format!("zoned byte {i} = {b:#04x} is not a valid ASCII digit/overpunch"),
            });
        }
    }
    out
}

/// Validate an EBCDIC zoned-decimal field: zone nibble `0xF` on data bytes; the sign byte zone is
/// `0xF` (unsigned), `0xC` (positive) or `0xD` (negative); digit nibble always 0-9.
pub fn check_zoned_ebcdic(bytes: &[u8], signed: bool, record: usize, field: &str, offset: usize) -> Vec<Finding> {
    let mut out = Vec::new();
    if bytes.is_empty() {
        return out;
    }
    let last = bytes.len() - 1;
    for (i, &b) in bytes.iter().enumerate() {
        let zone = b >> 4;
        let digit = b & 0x0F;
        let zone_ok = if signed && i == last {
            matches!(zone, 0xF | 0xC | 0xD)
        } else {
            zone == 0xF
        };
        if !zone_ok || digit > 9 {
            out.push(Finding {
                rule: Rule::ZonedBadByte,
                severity: Severity::Damage,
                record,
                field: Some(field.to_string()),
                offset: Some(offset + i),
                detail: format!("ebcdic zoned byte {i} = {b:#04x} (zone {zone:#x}, digit {digit:#x}) invalid"),
            });
        }
    }
    out
}

/// Scan a record's bytes for transfer artifacts: CR/LF injection and a SUB (0x1A) soft-EOF.
pub fn scan_transfer_artifacts(record_bytes: &[u8], record: usize, base: usize) -> Vec<Finding> {
    let mut out = Vec::new();
    for (i, &b) in record_bytes.iter().enumerate() {
        match b {
            0x0D | 0x0A => out.push(Finding {
                rule: Rule::CrlfInjection,
                severity: Severity::Damage,
                record,
                field: None,
                offset: Some(base + i),
                detail: format!(
                    "{} byte inside a fixed record (FTP ASCII-mode corruption)",
                    if b == 0x0D { "CR" } else { "LF" }
                ),
            }),
            0x1A => out.push(Finding {
                rule: Rule::SubEofByte,
                severity: Severity::Warn,
                record,
                field: None,
                offset: Some(base + i),
                detail: "0x1A (SUB / DOS soft-EOF) embedded in record".to_string(),
            }),
            _ => {}
        }
    }
    out
}

/// Triage a whole byte image against a layout. Splits into LRECL records, runs every field check
/// plus per-record transfer-artifact scanning, and aggregates a fail-closed [`Report`].
pub fn triage(data: &[u8], layout: &Layout) -> Report {
    let mut findings = Vec::new();
    let lrecl = layout.lrecl.max(1);
    let whole = data.len() / lrecl;
    let trailing = data.len() - whole * lrecl;

    if trailing != 0 {
        findings.push(Finding {
            rule: Rule::RecordLengthAnomaly,
            severity: Severity::Damage,
            record: WHOLE_FILE,
            field: None,
            offset: Some(whole * lrecl),
            detail: format!(
                "{trailing} trailing bytes after {whole} whole records (lrecl={lrecl}) -> truncated/padded transfer"
            ),
        });
    }

    for r in 0..whole {
        let base = r * lrecl;
        let rec = &data[base..base + lrecl];
        findings.extend(scan_transfer_artifacts(rec, r, base));
        for f in &layout.fields {
            let end = f.offset + f.len;
            if end > lrecl {
                findings.push(Finding {
                    rule: Rule::RecordLengthAnomaly,
                    severity: Severity::Damage,
                    record: r,
                    field: Some(f.name.clone()),
                    offset: Some(base + f.offset),
                    detail: format!("field '{}' [{}..{}] exceeds lrecl {lrecl}", f.name, f.offset, end),
                });
                continue;
            }
            let fb = &rec[f.offset..end];
            let abs = base + f.offset;
            match f.encoding {
                Encoding::Alphanumeric => {}
                Encoding::Packed { digits, .. } => {
                    findings.extend(check_packed(fb, digits, r, &f.name, abs));
                }
                Encoding::ZonedAscii { signed } => {
                    findings.extend(check_zoned_ascii(fb, signed, r, &f.name, abs));
                }
                Encoding::ZonedEbcdic { signed } => {
                    findings.extend(check_zoned_ebcdic(fb, signed, r, &f.name, abs));
                }
            }
        }
    }

    let damage = findings.iter().filter(|f| f.severity == Severity::Damage).count();
    let warn = findings.iter().filter(|f| f.severity == Severity::Warn).count();
    let info = findings.iter().filter(|f| f.severity == Severity::Info).count();
    Report {
        lrecl,
        total_bytes: data.len(),
        whole_records: whole,
        trailing_bytes: trailing,
        findings,
        damage,
        warn,
        info,
        clean: damage == 0,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn clean_packed_passes() {
        // 12345 positive in COMP-3 PIC S9(5): 3 bytes 0x12 0x34 0x5C
        let f = check_packed(&[0x12, 0x34, 0x5C], 5, 0, "amt", 0);
        assert!(f.is_empty(), "{f:?}");
    }

    #[test]
    fn packed_bad_digit_and_sign_caught() {
        // 0xA in a digit nibble + bad sign 0x1
        let f = check_packed(&[0x1A, 0x34, 0x51], 5, 0, "amt", 0);
        assert!(f.iter().any(|x| x.rule == Rule::PackedBadDigitNibble));
        assert!(f.iter().any(|x| x.rule == Rule::PackedBadSignNibble && x.severity == Severity::Damage));
    }

    #[test]
    fn packed_width_mismatch() {
        let f = check_packed(&[0x12, 0x3C], 5, 0, "amt", 0);
        assert_eq!(f.len(), 1);
        assert_eq!(f[0].rule, Rule::PackedWidthMismatch);
    }

    #[test]
    fn zoned_ascii_overpunch_ok_and_damage() {
        // "1234" + 'p' (0x70) = negative 0 overpunch -> clean
        assert!(check_zoned_ascii(b"1234p", true, 0, "n", 0).is_empty());
        // a CR injected mid-field -> damage
        let bad = check_zoned_ascii(b"12\x0d45", false, 0, "n", 0);
        assert!(bad.iter().any(|x| x.rule == Rule::ZonedBadByte));
    }

    #[test]
    fn ebcdic_zoned_sign_byte() {
        // F1 F2 C3 = +123 EBCDIC zoned signed
        assert!(check_zoned_ebcdic(&[0xF1, 0xF2, 0xC3], true, 0, "n", 0).is_empty());
        // a stray 0x01 zone is damage
        assert!(!check_zoned_ebcdic(&[0x01, 0xF2, 0xC3], true, 0, "n", 0).is_empty());
    }

    #[test]
    fn triage_detects_record_length_and_crlf() {
        let layout = Layout {
            lrecl: 4,
            fields: vec![Field { name: "x".into(), offset: 0, len: 4, encoding: Encoding::Alphanumeric }],
        };
        // 9 bytes, lrecl 4 -> 2 whole records + 1 trailing; record 1 has a CR
        let data = b"ABCDA\x0dCDE";
        let rep = triage(data, &layout);
        assert_eq!(rep.whole_records, 2);
        assert_eq!(rep.trailing_bytes, 1);
        assert!(rep.findings.iter().any(|f| f.rule == Rule::RecordLengthAnomaly));
        assert!(rep.findings.iter().any(|f| f.rule == Rule::CrlfInjection));
        assert!(!rep.clean);
    }

    #[test]
    fn clean_data_is_clean() {
        let layout = Layout {
            lrecl: 8,
            fields: vec![
                Field { name: "id".into(), offset: 0, len: 5, encoding: Encoding::ZonedAscii { signed: false } },
                Field { name: "amt".into(), offset: 5, len: 3, encoding: Encoding::Packed { digits: 5, signed: true } },
            ],
        };
        let data = [b'1', b'2', b'3', b'4', b'5', 0x12, 0x34, 0x5C];
        let rep = triage(&data, &layout);
        assert!(rep.clean, "{:?}", rep.findings);
        assert_eq!(rep.damage, 0);
    }
}
