// -----------------------------------------------------------------------------
// Message catalogue.
//
// Four messages share the link. Only SENDCARTON is framed and parsed here; the
// others are listed because their lengths are what makes a mis-framed record
// recognisable, and because CARTONRESPONSE is what we will have to send back.
// -----------------------------------------------------------------------------
public static class SendCartonMessage
{
    /// <summary>Inbound. One carton to ship. The only message this listener parses.</summary>
    public const int SendCartonLength = 1262;

    /// <summary>Outbound. Our rate and tracking number for one carton.</summary>
    public const int CartonResponseLength = 200;

    /// <summary>Inbound. Void a carton.</summary>
    public const int UnfreightLength = 43;

    /// <summary>Outbound. Result of a void.</summary>
    public const int UnfreightStatusLength = 80;
}

// -----------------------------------------------------------------------------
// One parsed SENDCARTON record. Every field is the trimmed source string:
// parsing to a number is a separate decision, made where the number is used, so
// that a value which will not parse stays visible instead of silently becoming
// zero.
//
// Field positions come straight from e-warehouse_Clippership_Fields_Mapping.xlsx
// (message = SENDCARTON) and are 1-based and inclusive.
// -----------------------------------------------------------------------------
public class SendCartonRecord
{
    // --- identity of the record itself ---------------------------------------
    /// <summary>The record exactly as framed, line wraps already removed.</summary>
    public string Raw;

    /// <summary>1-based arrival position within the connection. Diagnostics only.</summary>
    public int SequenceNumber;

    /// <summary>Null when the record parsed. Set, and the record still lands, when it did not.</summary>
    public string ParseError;

    // --- station / order ------------------------------------------------------
    public string StationId;        //    1 -    3
    public string OperatorId;       //    4 -   13
    public string Order;            //   14 -   28
    public string Carton;           //   29 -   38

    // --- carton ---------------------------------------------------------------
    public string Weight;           //   39 -   47
    public string Cost;             //   48 -   53
    public string Length;           //   54 -   59
    public string Width;            //   60 -   65
    public string Height;           //   66 -   71
    public string DimUnit;          //   72 -   73

    // --- consignee ------------------------------------------------------------
    public string ShipToId;         //   74 -   83
    public string ShipToName;       //   84 -  118
    public string ShipToAddr1;      //  119 -  153
    public string ShipToAddr2;      //  154 -  188
    public string ShipToAttn;       //  189 -  223
    public string ShipToCity;       //  224 -  248
    public string ShipToState;      //  249 -  258
    public string ShipToZip;        //  259 -  270
    public string ShipToCountry;    //  271 -  286

    // --- service and billing --------------------------------------------------
    public string Carrier;          //  287 -  296
    public string Mode;             //  297 -  298
    public string ChargeCode;       //  299 -  299
    public string InvoiceCode;      //  300 -  300
    public string TotVal;           //  301 -  313
    public string Shipper;          //  314 -  322
    public string CustomerNr;       //  323 -  332

    // --- the line item on this carton ----------------------------------------
    public string PartNr;           //  333 -  347
    public string PartDesc;         //  348 -  382
    public string Upc;              //  383 -  393
    public string AltOrder;         //  394 -  409
    public string CustPo;           //  410 -  424

    // --- the two grouping flags. The whole design rests on these. -------------
    public string LastTran;         //  425 -  425
    public string LastCart;         //  426 -  426

    // --- sold-to / bill-to ----------------------------------------------------
    public string CustName;         //  427 -  461
    public string CustAddr1;        //  462 -  496
    public string CustAddr2;        //  497 -  531
    public string CustAttn;         //  532 -  566
    public string CustCity;         //  567 -  591
    public string CustState;        //  592 -  601
    public string CustZip;          //  602 -  613
    public string CustCountry;      //  614 -  629

    public string DestZone;         //  630 -  679
    public string ThirdPartyBillAcct; // 680 -  729

    public string Custom1;          //  730 -  779
    public string Custom2;          //  780 -  829
    public string Custom3;          //  830 -  879
    public string Custom4;          //  880 - 1129

    // --- carton counts and the tail ------------------------------------------
    public string CartonSeq;        // 1130 - 1134
    public string CartonTotal;      // 1135 - 1139

    /// <summary>
    /// UNIT_TOTAL. The sample carries "1" on a one-carton order, which is
    /// consistent with both "units in this carton" and "units on this order",
    /// so it does not settle the question. It is stored as given and is NOT
    /// summed anywhere. Confirm the meaning before it drives a customs value or
    /// a commercial invoice.
    /// </summary>
    public string UnitTotal;        // 1140 - 1144

    public string OrderExt;         // 1145 - 1154
    public string ShipToAddr3;      // 1155 - 1189
    public string TotOrdVal;        // 1190 - 1202
    public string FreightType;      // 1203 - 1212
    public string Phone;            // 1213 - 1242
    public string CustPoNo;         // 1243 - 1262

    /// <summary>LASTCART = 'Y': the final carton of this order.</summary>
    public bool IsLastCarton
    {
        get { return IsYes(LastCart); }
    }

    /// <summary>
    /// LASTTRAN = 'Y': the final record of this transaction. This is what closes
    /// a shipment - see AutoShipStreamListener.
    /// </summary>
    public bool IsLastTransaction
    {
        get { return IsYes(LastTran); }
    }

    public bool ParsedOk
    {
        get { return ParseError == null; }
    }

    /// <summary>
    /// The key records are grouped into orders by. ORDER_EXT participates only
    /// when AutoShipStreamConfig.SeparateOrdersByOrderExt says so - see the note
    /// on that setting.
    /// </summary>
    public string OrderKey
    {
        get
        {
            if (AutoShipStreamConfig.SeparateOrdersByOrderExt)
            {
                return Order + "|" + (OrderExt == null ? "" : OrderExt);
            }

            return Order;
        }
    }

    private static bool IsYes(string value)
    {
        return value != null && value.Length == 1 && (value[0] == 'Y' || value[0] == 'y');
    }
}

// -----------------------------------------------------------------------------
// Parsing.
// -----------------------------------------------------------------------------
public static class SendCartonParser
{
    /// <summary>
    /// Parses one framed record. Never throws and never returns null: a record
    /// it cannot make sense of comes back with ParseError set and Raw intact,
    /// because the sender will not send it again and a dropped record leaves
    /// nothing to diagnose.
    /// </summary>
    public static SendCartonRecord Parse(string raw, int sequenceNumber)
    {
        SendCartonRecord r = new SendCartonRecord();
        r.Raw = raw;
        r.SequenceNumber = sequenceNumber;

        if (raw == null || raw.Length < SendCartonMessage.SendCartonLength)
        {
            r.ParseError =
                "Short record: " + (raw == null ? 0 : raw.Length) + " characters, expected " +
                SendCartonMessage.SendCartonLength + ".";
            return r;
        }

        try
        {
            r.StationId          = Cut(raw,    1,    3);
            r.OperatorId         = Cut(raw,    4,   13);
            r.Order              = Cut(raw,   14,   28);
            r.Carton             = Cut(raw,   29,   38);
            r.Weight             = Cut(raw,   39,   47);
            r.Cost               = Cut(raw,   48,   53);
            r.Length             = Cut(raw,   54,   59);
            r.Width              = Cut(raw,   60,   65);
            r.Height             = Cut(raw,   66,   71);
            r.DimUnit            = Cut(raw,   72,   73);
            r.ShipToId           = Cut(raw,   74,   83);
            r.ShipToName         = Cut(raw,   84,  118);
            r.ShipToAddr1        = Cut(raw,  119,  153);
            r.ShipToAddr2        = Cut(raw,  154,  188);
            r.ShipToAttn         = Cut(raw,  189,  223);
            r.ShipToCity         = Cut(raw,  224,  248);
            r.ShipToState        = Cut(raw,  249,  258);
            r.ShipToZip          = Cut(raw,  259,  270);
            r.ShipToCountry      = Cut(raw,  271,  286);
            r.Carrier            = Cut(raw,  287,  296);
            r.Mode               = Cut(raw,  297,  298);
            r.ChargeCode         = Cut(raw,  299,  299);
            r.InvoiceCode        = Cut(raw,  300,  300);
            r.TotVal             = Cut(raw,  301,  313);
            r.Shipper            = Cut(raw,  314,  322);
            r.CustomerNr         = Cut(raw,  323,  332);
            r.PartNr             = Cut(raw,  333,  347);
            r.PartDesc           = Cut(raw,  348,  382);
            r.Upc                = Cut(raw,  383,  393);
            r.AltOrder           = Cut(raw,  394,  409);
            r.CustPo             = Cut(raw,  410,  424);
            r.LastTran           = Cut(raw,  425,  425);
            r.LastCart           = Cut(raw,  426,  426);
            r.CustName           = Cut(raw,  427,  461);
            r.CustAddr1          = Cut(raw,  462,  496);
            r.CustAddr2          = Cut(raw,  497,  531);
            r.CustAttn           = Cut(raw,  532,  566);
            r.CustCity           = Cut(raw,  567,  591);
            r.CustState          = Cut(raw,  592,  601);
            r.CustZip            = Cut(raw,  602,  613);
            r.CustCountry        = Cut(raw,  614,  629);
            r.DestZone           = Cut(raw,  630,  679);
            r.ThirdPartyBillAcct = Cut(raw,  680,  729);
            r.Custom1            = Cut(raw,  730,  779);
            r.Custom2            = Cut(raw,  780,  829);
            r.Custom3            = Cut(raw,  830,  879);
            r.Custom4            = Cut(raw,  880, 1129);
            r.CartonSeq          = Cut(raw, 1130, 1134);
            r.CartonTotal        = Cut(raw, 1135, 1139);
            r.UnitTotal          = Cut(raw, 1140, 1144);
            r.OrderExt           = Cut(raw, 1145, 1154);
            r.ShipToAddr3        = Cut(raw, 1155, 1189);
            r.TotOrdVal          = Cut(raw, 1190, 1202);
            r.FreightType        = Cut(raw, 1203, 1212);
            r.Phone              = Cut(raw, 1213, 1242);
            r.CustPoNo           = Cut(raw, 1243, 1262);
        }
        catch (Exception ex)
        {
            r.ParseError = "Unreadable record: " + ex.Message;
            return r;
        }

        r.ParseError = Validate(r);
        return r;
    }

    /// <summary>
    /// Cheap structural check, and the only thing standing between a mis-framed
    /// stream and a shipment built from the wrong bytes.
    ///
    /// Framing is by length, so if the sender ever emits a record of the wrong
    /// length the stream slips and every record after it is shifted - and a
    /// shifted record still looks like a record. It has an order number, a
    /// carton number and an address; they just belong to the wrong fields.
    /// Nothing downstream can tell.
    ///
    /// So the check has to be on fields whose CONTENT is constrained, not on
    /// fields merely being present:
    ///
    ///   LASTTRAN / LASTCART   one character, and only Y or N
    ///   WEIGHT                a number when it is not blank
    ///   CARTON_SEQ / _TOTAL   integers when they are not blank
    ///
    /// The flags do most of the work, and only because
    /// RequireTransactionFlags demands they be populated. Accepting blank there
    /// is what let a record shifted by three characters through in testing: the
    /// shift landed both flags inside CUST_NAME, which is blank on a drop-ship,
    /// and blank read as legal. If a sender genuinely leaves them blank, turn
    /// that setting off - and lose most of the protection with it.
    /// </summary>
    private static string Validate(SendCartonRecord r)
    {
        string flag = CheckFlag("LASTTRAN", 425, r.LastTran);
        if (flag != null)
        {
            return flag;
        }

        flag = CheckFlag("LASTCART", 426, r.LastCart);
        if (flag != null)
        {
            return flag;
        }

        if (r.Order.Length == 0)
        {
            return "ORDER at characters 14-28 is blank.";
        }

        if (r.Carton.Length == 0)
        {
            return "CARTON at characters 29-38 is blank.";
        }

        string number = CheckNumber("WEIGHT", 39, 47, r.Weight);
        if (number != null)
        {
            return number;
        }

        number = CheckInteger("CARTON_SEQ", 1130, 1134, r.CartonSeq);
        if (number != null)
        {
            return number;
        }

        return CheckInteger("CARTON_TOTAL", 1135, 1139, r.CartonTotal);
    }

    private static string CheckFlag(string name, int position, string value)
    {
        if (value != null && value.Length == 1)
        {
            char c = value[0];
            if (c == 'Y' || c == 'y' || c == 'N' || c == 'n')
            {
                return null;
            }
        }

        if (!AutoShipStreamConfig.RequireTransactionFlags && (value == null || value.Length == 0))
        {
            return null;
        }

        return name + " at character " + position + " is \"" + value +
               "\", expected Y or N. The stream is probably mis-framed.";
    }

    private static string CheckNumber(string name, int start, int end, string value)
    {
        if (string.IsNullOrEmpty(value) || SendCartonParser.ToDecimal(value) != null)
        {
            return null;
        }

        return name + " at characters " + start + "-" + end + " is \"" + value +
               "\", which is not a number. The stream is probably mis-framed.";
    }

    private static string CheckInteger(string name, int start, int end, string value)
    {
        if (string.IsNullOrEmpty(value) || SendCartonParser.ToInt(value) != null)
        {
            return null;
        }

        return name + " at characters " + start + "-" + end + " is \"" + value +
               "\", which is not a whole number. The stream is probably mis-framed.";
    }

    /// <summary>1-based, inclusive, trimmed - the convention the mapping sheet uses.</summary>
    private static string Cut(string raw, int startPosition, int endPosition)
    {
        return raw.Substring(startPosition - 1, endPosition - startPosition + 1).Trim();
    }

    // --- value conversions ---------------------------------------------------
    // Invariant culture throughout: the feed writes "37.60000000" regardless of
    // what the machine's regional settings say, and a comma-decimal locale would
    // otherwise turn it into 3760000000.

    public static object ToDecimal(string value)
    {
        decimal parsed;
        if (!string.IsNullOrEmpty(value) &&
            decimal.TryParse(value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out parsed))
        {
            return parsed;
        }

        return null;
    }

    public static object ToInt(string value)
    {
        int parsed;
        if (!string.IsNullOrEmpty(value) &&
            int.TryParse(value, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out parsed))
        {
            return parsed;
        }

        return null;
    }
}

// -----------------------------------------------------------------------------
// Framing: a byte stream back into records.
//
// TCP hands you whatever has arrived, with no relationship to record
// boundaries, so something has to say where a record ends. In this feed that is
// LENGTH, not a delimiter:
//
//   the sample record is 1262 characters carrying a CRLF after character 1023
//
// 1023 is not a field boundary - it falls inside CUSTOM4 - so that CRLF is the
// sender wrapping its output, not terminating a record. Splitting on CRLF, as
// the previous version did, turns that one record into a 1023-character record
// and a 239-character one, and both parse to nonsense.
//
// So: discard CR and LF wherever they appear, and cut every RecordLength
// characters. A fixed-width record of printable business data cannot legally
// contain either character, which is what makes discarding them safe. It also
// makes the framing indifferent to CRLF vs LF vs CR, to a sender that wraps and
// one that does not, and to two records arriving back to back with nothing
// between them.
//
// THE COST of length framing is that it cannot resynchronise: if the sender
// ever emits a record of the wrong length, every record after it is shifted.
// That is what SendCartonParser.Validate is for - the shift shows up
// immediately as a bad LASTTRAN/LASTCART rather than as a plausible shipment
// built from the wrong bytes.
//
// THE ASSUMPTION is that this port carries SENDCARTON only. UNFREIGHT (43
// characters) on the same connection would be mis-framed. Nothing observed so
// far says it shares the port; if it turns out to, this class needs a
// discriminator rather than a constant.
// -----------------------------------------------------------------------------
public class SendCartonFramer
{
    private readonly System.Text.StringBuilder _buffer = new System.Text.StringBuilder();
    private readonly int _recordLength;

    public SendCartonFramer(int recordLength)
    {
        _recordLength = recordLength;
    }

    /// <summary>Characters held back waiting for the rest of their record.</summary>
    public int Pending
    {
        get { return _buffer.Length; }
    }

    /// <summary>
    /// Feeds a chunk off the socket and returns every record that is now
    /// complete. Returns an empty list, not null, when none is.
    /// </summary>
    public List<string> Add(string chunk)
    {
        List<string> records = new List<string>();

        if (chunk != null)
        {
            for (int i = 0; i < chunk.Length; i++)
            {
                char c = chunk[i];
                if (c == '\r' || c == '\n')
                {
                    continue;
                }

                _buffer.Append(c);

                if (_buffer.Length == _recordLength)
                {
                    records.Add(_buffer.ToString());
                    _buffer.Length = 0;
                }
            }
        }

        return records;
    }

    /// <summary>
    /// Whatever is left over, and clears it. Non-empty at end of connection
    /// means a truncated record: the caller must land it with an error rather
    /// than discard it.
    /// </summary>
    public string TakeRemainder()
    {
        string remainder = _buffer.ToString();
        _buffer.Length = 0;
        return remainder;
    }
}

// -----------------------------------------------------------------------------
// One ORDER inside a shipment. Becomes one ShipLink Order, with one OrderDetail
// per carton record and one Parcel per carton record.
// -----------------------------------------------------------------------------
public class SendCartonOrderGroup
{
    /// <summary>1-based position within the shipment, in arrival order.</summary>
    public int SeqNo;

    public string OrderKey;

    /// <summary>
    /// The first record seen for this order. Every order-level field is repeated
    /// on every carton of the order, so one record is enough - and taking them
    /// all from the same record means a shipment never mixes half of one
    /// address with half of another.
    /// </summary>
    public SendCartonRecord Header;

    /// <summary>Every carton record of this order, in arrival order.</summary>
    public readonly List<SendCartonRecord> Records = new List<SendCartonRecord>();

    /// <summary>A record with LASTCART = 'Y' was seen. False means the order is short some cartons.</summary>
    public bool SawLastCarton;

    /// <summary>CARTON_TOTAL as declared, or null if it never parsed.</summary>
    public object DeclaredCartonTotal;

    /// <summary>Populated by Check(); null when the order looks consistent.</summary>
    public string ErrorMessage;

    public string OrderNumber
    {
        get { return Header == null ? null : Header.Order; }
    }

    /// <summary>
    /// Compares what arrived against what the sender said would arrive. This
    /// does not reject anything - an order that is short a carton is still
    /// shipped, because refusing to ship is not obviously safer than shipping
    /// short - but it makes the discrepancy a stored fact rather than a
    /// surprise at the loading bay.
    /// </summary>
    public void Check()
    {
        List<string> problems = new List<string>();

        if (!SawLastCarton)
        {
            problems.Add("no record carried LASTCART = 'Y'");
        }

        if (DeclaredCartonTotal != null && (int)DeclaredCartonTotal != Records.Count)
        {
            problems.Add("CARTON_TOTAL says " + (int)DeclaredCartonTotal +
                         " carton(s) but " + Records.Count + " arrived");
        }

        // Every carton of an order repeats the order-level fields. If two of
        // them disagree, one of the two shipments' worth of data is wrong and
        // there is no way to tell which - so say so rather than silently using
        // whichever arrived first.
        string mismatch = FirstHeaderMismatch();
        if (mismatch != null)
        {
            problems.Add(mismatch);
        }

        ErrorMessage = problems.Count == 0 ? null : string.Join("; ", problems.ToArray());
    }

    private string FirstHeaderMismatch()
    {
        for (int i = 1; i < Records.Count; i++)
        {
            SendCartonRecord r = Records[i];

            if (!Same(r.ShipToName, Header.ShipToName)) { return Differs("SHIPTONAME", i, r.ShipToName, Header.ShipToName); }
            if (!Same(r.ShipToAddr1, Header.ShipToAddr1)) { return Differs("SHIPTOADR1", i, r.ShipToAddr1, Header.ShipToAddr1); }
            if (!Same(r.ShipToZip, Header.ShipToZip)) { return Differs("SHIPTOZIP", i, r.ShipToZip, Header.ShipToZip); }
            if (!Same(r.Carrier, Header.Carrier)) { return Differs("CARRIER", i, r.Carrier, Header.Carrier); }
        }

        return null;
    }

    private static string Differs(string field, int index, string got, string expected)
    {
        return "carton " + (index + 1) + " has " + field + " \"" + got +
               "\" but the first carton has \"" + expected + "\"";
    }

    private static bool Same(string a, string b)
    {
        return string.Equals(a == null ? "" : a, b == null ? "" : b, StringComparison.Ordinal);
    }
}

// -----------------------------------------------------------------------------
// One TRANSACTION - the unit that becomes one shipment, one ShipRequest, one
// row in ShipmentData.
//
// Accumulated per connection. Records go in as they arrive; Build() turns them
// into orders once the transaction is closed.
// -----------------------------------------------------------------------------
public class SendCartonShipment
{
    public string SourceEndpoint;
    public object PCId;

    /// <summary>Every record, in arrival order. One per parcel, including the ones that would not parse.</summary>
    public readonly List<SendCartonRecord> Records = new List<SendCartonRecord>();

    /// <summary>Built by Build(). First-seen order first.</summary>
    public readonly List<SendCartonOrderGroup> Orders = new List<SendCartonOrderGroup>();

    /// <summary>One of the ShipmentCloseReason constants. Anything but LastTran is suspect - see the schema.</summary>
    public string CloseReason;

    public string ErrorMessage;

    /// <summary>Wall clock of the most recent record, for the idle flush.</summary>
    public DateTime LastRecordAt = DateTime.UtcNow;

    public bool IsEmpty
    {
        get { return Records.Count == 0; }
    }

    /// <summary>
    /// The first order number in the transaction, denormalised onto ShipmentData
    /// so the common single-order lookup needs no join. On a multi-order
    /// shipment this identifies the shipment only loosely - the rest are in
    /// ShipmentOrder.
    /// </summary>
    public string PrimaryOrderNumber
    {
        get { return Orders.Count == 0 ? null : Orders[0].OrderNumber; }
    }

    public bool IsMultiOrder
    {
        get { return Orders.Count > 1; }
    }

    public void Add(SendCartonRecord record)
    {
        Records.Add(record);
        LastRecordAt = DateTime.UtcNow;
    }

    /// <summary>
    /// Groups the records into orders, preserving arrival order, and checks each
    /// one. Call once, after the transaction is closed.
    ///
    /// Records that did not parse are deliberately left out of every order: they
    /// have no trustworthy ORDER to group by. They stay in Records, so they
    /// still become parcels - with no order attached and their error stored -
    /// and the shipment carries a message saying how many.
    /// </summary>
    public void Build()
    {
        Orders.Clear();

        Dictionary<string, SendCartonOrderGroup> byKey = new Dictionary<string, SendCartonOrderGroup>(StringComparer.Ordinal);
        int unparsed = 0;
        int orderExtVariance = 0;

        for (int i = 0; i < Records.Count; i++)
        {
            SendCartonRecord record = Records[i];

            if (!record.ParsedOk)
            {
                unparsed++;
                continue;
            }

            SendCartonOrderGroup group;
            if (!byKey.TryGetValue(record.OrderKey, out group))
            {
                group = new SendCartonOrderGroup();
                group.OrderKey = record.OrderKey;
                group.Header = record;
                group.SeqNo = Orders.Count + 1;
                group.DeclaredCartonTotal = SendCartonParser.ToInt(record.CartonTotal);

                byKey.Add(record.OrderKey, group);
                Orders.Add(group);
            }
            else if (!AutoShipStreamConfig.SeparateOrdersByOrderExt &&
                     !string.Equals(record.OrderExt, group.Header.OrderExt, StringComparison.Ordinal))
            {
                orderExtVariance++;
            }

            group.Records.Add(record);

            if (record.IsLastCarton)
            {
                group.SawLastCarton = true;
            }
        }

        List<string> problems = new List<string>();

        for (int i = 0; i < Orders.Count; i++)
        {
            Orders[i].Check();
            if (Orders[i].ErrorMessage != null)
            {
                problems.Add("order " + Orders[i].OrderNumber + ": " + Orders[i].ErrorMessage);
            }
        }

        if (unparsed > 0)
        {
            problems.Add(unparsed + " of " + Records.Count +
                         " record(s) could not be parsed and are stored as parcels with no order");
        }

        if (orderExtVariance > 0)
        {
            // Two readings of ORDER_EXT are possible and the sample - a single
            // record carrying "0" - does not choose between them. If it turns
            // out to be a release/extension number, these are separate orders
            // that have just been merged; set SeparateOrdersByOrderExt.
            problems.Add(orderExtVariance + " record(s) carry an ORDER_EXT that differs from the first carton of " +
                         "their order; if ORDER_EXT distinguishes orders, set SeparateOrdersByOrderExt");
        }

        if (CloseReason != null && CloseReason != ShipmentCloseReason.LastTran)
        {
            problems.Add("transaction was closed by " + CloseReason + ", not by LASTTRAN = 'Y'");
        }

        ErrorMessage = problems.Count == 0 ? null : string.Join("; ", problems.ToArray());
    }

    public string Describe()
    {
        return "shipment of " + Orders.Count + " order(s) / " + Records.Count +
               " parcel(s) [" + (PrimaryOrderNumber == null ? "?" : PrimaryOrderNumber) +
               (IsMultiOrder ? " +" + (Orders.Count - 1) : "") + "] from " + SourceEndpoint;
    }
}

// -----------------------------------------------------------------------------
// A standing self-test, so the layout can be checked without a sender, a socket
// or a database. Put it in a formula body and run it:
//
//     LogHelper.LogMessage(SendCartonSelfTest.Run());
//
// The literal below is the supplied sample, with its wrap already removed.
// -----------------------------------------------------------------------------
public static class SendCartonSelfTest
{
    public static string Run()
    {
        // Generated from eWarehouse2Clippership_String 1 1.txt, wrap removed,
        // cut into 70-character pieces. Do not reflow it by hand - the trailing
        // spaces are load-bearing.
        string raw =
            "225sng       8441568        48519969  1.0000000                       " +
            "   1078110   BUCKLEY MAIN W/BAYS                AAFES #1078110        " +
            "             365 N TELLURIDE ST                                       " +
            "             BUCKLEY AFB              CO        800117809             " +
            "      U11P        8 37.60000000           29894     8396393           " +
            "                                           1078110         0069383172 " +
            "    NY                                                                " +
            "                                                                      " +
            "                                                                      " +
            "                                                                      " +
            "                             00007168250082882779                     " +
            "                                                           PAJ INC    " +
            "                                                                      " +
            "                                                                      " +
            "                                                                      " +
            "                                                                      " +
            "         1    1    1    0         STOP 76                            3" +
            "7.60000000  8                                       0069383172        " +
            "  ";

        System.Text.StringBuilder report = new System.Text.StringBuilder();
        report.Append("SendCartonSelfTest: length ").Append(raw.Length)
              .Append(", expected ").Append(SendCartonMessage.SendCartonLength).AppendLine();

        if (raw.Length != SendCartonMessage.SendCartonLength)
        {
            report.AppendLine("FAIL - the literal in this file is the wrong length; fix it before trusting anything below.");
            return report.ToString();
        }

        SendCartonRecord r = SendCartonParser.Parse(raw, 1);

        Check(report, "ParseError", r.ParseError, null);
        Check(report, "STATION_ID", r.StationId, "225");
        Check(report, "OPERATOR", r.OperatorId, "sng");
        Check(report, "ORDER", r.Order, "8441568");
        Check(report, "CARTON", r.Carton, "48519969");
        Check(report, "WEIGHT", r.Weight, "1.0000000");
        Check(report, "SHIPTOID", r.ShipToId, "1078110");
        Check(report, "SHIPTONAME", r.ShipToName, "BUCKLEY MAIN W/BAYS");
        Check(report, "SHIPTOADR1", r.ShipToAddr1, "AAFES #1078110");
        Check(report, "SHIPTOADR2", r.ShipToAddr2, "365 N TELLURIDE ST");
        Check(report, "SHIPTOADR3", r.ShipToAddr3, "STOP 76");
        Check(report, "SHIPTOCITY", r.ShipToCity, "BUCKLEY AFB");
        Check(report, "SHIPTOSTAT", r.ShipToState, "CO");
        Check(report, "SHIPTOZIP", r.ShipToZip, "800117809");
        Check(report, "CARRIER", r.Carrier, "U11P");
        Check(report, "CHARGECODE", r.ChargeCode, "8");
        Check(report, "TOTVAL", r.TotVal, "37.60000000");
        Check(report, "CUSTOMERNR", r.CustomerNr, "29894");
        Check(report, "PART_NR", r.PartNr, "8396393");
        Check(report, "ALTORDER", r.AltOrder, "1078110");
        Check(report, "CUST_PO", r.CustPo, "0069383172");
        Check(report, "LASTTRAN", r.LastTran, "N");
        Check(report, "LASTCART", r.LastCart, "Y");
        Check(report, "CUSTOM1", r.Custom1, "00007168250082882779");
        Check(report, "CUSTOM3", r.Custom3, "PAJ INC");
        Check(report, "CARTON_SEQ", r.CartonSeq, "1");
        Check(report, "CARTON_TOTAL", r.CartonTotal, "1");
        Check(report, "UNIT_TOTAL", r.UnitTotal, "1");
        Check(report, "ORDER_EXT", r.OrderExt, "0");
        Check(report, "TOTORDVAL", r.TotOrdVal, "37.60000000");
        Check(report, "FREIGHT_TYPE", r.FreightType, "8");
        Check(report, "CUST_PONO", r.CustPoNo, "0069383172");

        Check(report, "IsLastCarton", r.IsLastCarton ? "true" : "false", "true");
        Check(report, "IsLastTransaction", r.IsLastTransaction ? "true" : "false", "false");

        // The wrap the sample arrives with, put back, to prove the framer removes it.
        SendCartonFramer framer = new SendCartonFramer(SendCartonMessage.SendCartonLength);
        List<string> framed = framer.Add(raw.Substring(0, 1023) + "\r\n" + raw.Substring(1023) + "\r\n");

        Check(report, "framer record count", framed.Count.ToString(), "1");
        Check(report, "framer remainder", framer.Pending.ToString(), "0");
        if (framed.Count == 1)
        {
            Check(report, "framer round trip", framed[0] == raw ? "same" : "different", "same");
        }

        return report.ToString();
    }

    private static void Check(System.Text.StringBuilder report, string label, string actual, string expected)
    {
        bool ok = string.Equals(actual, expected, StringComparison.Ordinal);
        report.Append(ok ? "  ok   " : "  FAIL ").Append(label).Append(" = \"").Append(actual).Append("\"");

        if (!ok)
        {
            report.Append("  (expected \"").Append(expected).Append("\")");
        }

        report.AppendLine();
    }
}
