public static class AutoShipStreamPrinting
{
    private const string SystemPrinterPrefix = "System Printers.";

    private static volatile bool _halted;
    private static string _haltReason;
    private static string _printerName;
    private static bool _recovered;

    public static bool IsHalted { get { return _halted; } }

    public static string HaltReason { get { return _haltReason; } }

    /// <summary>
    /// Clears a halt after an operator has resolved it. Deliberately manual:
    /// every condition that halts this loop is one where guessing produces either
    /// a duplicate label or a missing one.
    ///
    /// Putting the halted batches back in the queue is not optional. Halting
    /// marks a batch Failed, and ProcessNextBatch only ever selects Processing
    /// and Printing - so a batch that halted drops out of the queue for good.
    /// Without this, Resume() would clear a flag and change nothing: the loop
    /// would carry on past the batch it stopped on and those labels would never
    /// print. It is also not always the halted batch's own fault - the
    /// out-of-order guard fails the batch it was ASKED to print because of a
    /// parcel in an earlier one, which is doubly unfair to leave stranded.
    ///
    /// Reviving every Failed batch rather than just the last one is deliberate:
    /// the in-memory record of which batch halted does not survive a restart,
    /// and Failed means exactly one thing here - "the printing loop stopped on
    /// this". Nothing is assumed to be fixed. Each halt condition is re-checked
    /// against the table on the next pass, so anything still unresolved simply
    /// halts again.
    /// </summary>
    public static void Resume()
    {
        _halted = false;
        _haltReason = null;

        int revived = AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentBatch
                 SET Status = @Processing
               WHERE Status = @Failed;",
            AutoShipDb.P("@Processing", BatchStatus.Processing),
            AutoShipDb.P("@Failed", BatchStatus.Failed));

         ShipLink.Helpers.LogHelper.LogMessage(
            "AutoShipStream: printing resumed by operator; " + revived +
            " halted batch(es) returned to the queue. Anything still unresolved will halt again on the next pass.");
    }

    public static void Run()
    {
        // Resolved once, not per label. PrinterName goes through Config.GetKeyValue,
        // which is a linear scan over a List<T> that SetKeyValue mutates in place -
        // reading it from this thread while someone saves the settings screen risks
        // a torn read or a "collection was modified" throw.
        _printerName = ResolvePrinterName();

        if (string.IsNullOrEmpty(_printerName))
        {
            ShipLink.Helpers.LogHelper.LogMessage(
                "AutoShipStream: no label printer is configured (System Setup > Device Setup > Printer). " +
                "The printing loop will not start.");
            return;
        }

        if (!_recovered)
        {
            RecoverOrphanedWork();
            _recovered = true;
        }

        ShipLink.Helpers.LogHelper.LogMessage(
            "AutoShipStream: printing loop started (every " + (AutoShipStreamConfig.PrintIntervalMs / 1000) +
            "s, printer '" + _printerName + "').");

        try
        {
            while (!AutoShipStreamControl.ShouldStop())
            {
                try
                {
                    if (!_halted)
                    {
                        ProcessNextBatch();
                    }
                }
                catch (Exception ex)
                {
                    ShipLink.Helpers.LogHelper.LogMessage("AutoShipStream: printing tick failed:" + Environment.NewLine + ex.ToString());
                }

                AutoShipStreamControl.Sleep(AutoShipStreamConfig.PrintIntervalMs);
            }
        }
        finally
        {
            ShipLink.Helpers.LogHelper.LogMessage("AutoShipStream: printing loop stopped.");
        }
    }

    private static void ProcessNextBatch()
    {
        // The oldest batch with printing still to do. Ordered by Id, with no
        // deferral or skip clause - this loop is meant to be blocked by an
        // unfinished batch, because moving past one prints out of order.
        object batchIdValue = AutoShipDb.Scalar(
            @"SELECT TOP (1) Id
                FROM dbo.ShipmentBatch
               WHERE Status IN (@Processing, @Printing)
               ORDER BY Id;",
            AutoShipDb.P("@Processing", BatchStatus.Processing),
            AutoShipDb.P("@Printing", BatchStatus.Printing));

        if (batchIdValue == null)
        {
            return;
        }

        long batchId = Convert.ToInt64(batchIdValue);

        AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentBatch
                 SET Status         = @Printing,
                     PrintStartedAt = COALESCE(PrintStartedAt, SYSUTCDATETIME())
               WHERE Id = @Id
                 AND Status = @Processing;",
            AutoShipDb.P("@Id", batchId),
            AutoShipDb.P("@Printing", BatchStatus.Printing),
            AutoShipDb.P("@Processing", BatchStatus.Processing));

        List<ShipmentPrintItem> queue = GetPrintQueue(batchId);
        if (queue.Count == 0)
        {
            // A batch of shipments that produced no parcels at all cannot be left
            // open: nothing will ever print it and it would block every batch
            // behind it forever.
            CompleteBatch(batchId);
            return;
        }

        if (!IsNextInLine(batchId, queue))
        {
            return;
        }

        foreach (ShipmentPrintItem item in queue)
        {
            if (AutoShipStreamControl.ShouldStop())
            {
                return;
            }

            // Already dealt with on an earlier pass.
            if (item.PrintStatus == PrintStatus.LabelPrinted || item.PrintStatus == PrintStatus.NoLabel)
            {
                continue;
            }

            // Left mid-send by a crash. Whether the label physically came out is
            // unknowable from here: reprinting risks a duplicate, skipping risks a
            // missing label. Neither is ours to choose.
            if (item.PrintStatus == PrintStatus.Sending)
            {
                Halt(batchId,
                    "Parcel " + item.Id + " (" + Describe(item) +
                    ") was interrupted mid-print. It is unknown whether the label was produced; " +
                    "resolve it before resuming.");
                return;
            }

            if (item.PrintStatus != PrintStatus.Pending)
            {
                // PrintFailed, and not retried automatically - a blind retry is how
                // a label comes out twice.
                Halt(batchId,
                    "Parcel " + item.Id + " (" + Describe(item) +
                    ") is in print state '" + item.PrintStatus + "' and needs attention.");
                return;
            }

            // Its shipment is still with the carrier. Stop here rather than
            // reaching past it - the deadline sweep will resolve it one way or
            // the other. Note this blocks on the SHIPMENT: a multi-order shipment
            // is one carrier call, so all of its cartons wait together.
            if (item.ShipStatus != ShipStatus.Shipped && item.ShipStatus != ShipStatus.Failed)
            {
                return;
            }

            if (!PrintItem(item, batchId))
            {
                return;
            }
        }

        CompleteBatch(batchId);
    }

    /// <summary>
    /// Confirms no carton outside this batch is due to print first.
    ///
    /// The whole ordering guarantee reduces to this one question: is any parcel
    /// with a lower Id still unprinted? Parcels whose shipment has not been
    /// claimed into a batch yet are excluded, because the claim always takes the
    /// lowest shipment Ids, so anything still Ready is necessarily behind
    /// everything already batched.
    ///
    /// What this catches is an earlier batch that was marked Failed and therefore
    /// dropped out of the queue above - the one situation where carrying on looks
    /// harmless and is not.
    /// </summary>
    private static bool IsNextInLine(long batchId, List<ShipmentPrintItem> queue)
    {
        long batchLowest = long.MaxValue;
        foreach (ShipmentPrintItem item in queue)
        {
            if (item.Id < batchLowest)
            {
                batchLowest = item.Id;
            }
        }

        // Two <> rather than NOT IN, to match the filtered index on
        // ShipmentParcel and because NOT IN is not indexable the same way.
        object nextDueValue = AutoShipDb.Scalar(
            @"SELECT MIN(p.Id)
                FROM dbo.ShipmentParcel p
                JOIN dbo.ShipmentData d ON d.Id = p.ShipmentData_Id
               WHERE d.ShipmentBatch_Id IS NOT NULL
                 AND p.PrintStatus <> @Printed
                 AND p.PrintStatus <> @NoLabel;",
            AutoShipDb.P("@Printed", PrintStatus.LabelPrinted),
            AutoShipDb.P("@NoLabel", PrintStatus.NoLabel));

        if (nextDueValue == null)
        {
            return true;
        }

        long nextDue = Convert.ToInt64(nextDueValue);
        if (nextDue >= batchLowest)
        {
            return true;
        }

        Halt(batchId,
            "Parcel " + nextDue + " is still unprinted and arrived before anything in batch " + batchId +
            " (which starts at " + batchLowest + "). Printing stopped rather than emitting labels out of order.");
        return false;
    }

    /// <summary>
    /// The batch's cartons in print order.
    ///
    /// Deliberately does not select ZPL or Data: for a large batch those are
    /// hundreds of megabytes, and the loop only needs one label's worth at a time.
    ///
    /// The order number comes from ShipmentOrder and is LEFT joined - a parcel
    /// whose record did not parse has no order, still occupies its place in the
    /// sequence, and still has to print something or be recorded as NoLabel.
    /// </summary>
    private static List<ShipmentPrintItem> GetPrintQueue(long batchId)
    {
        DataTable table = AutoShipDb.Query(
            @"SELECT p.Id, p.ShipmentData_Id, p.ShipmentOrder_Id, p.CartonId, p.PrintStatus,
                     d.ShipStatus, o.OrderNumber
                FROM dbo.ShipmentParcel p
                JOIN dbo.ShipmentData d ON d.Id = p.ShipmentData_Id
                LEFT JOIN dbo.ShipmentOrder o ON o.Id = p.ShipmentOrder_Id
               WHERE d.ShipmentBatch_Id = @BatchId
               ORDER BY p.Id;",
            AutoShipDb.P("@BatchId", batchId));

        List<ShipmentPrintItem> items = new List<ShipmentPrintItem>();

        foreach (DataRow row in table.Rows)
        {
            ShipmentPrintItem item = new ShipmentPrintItem();
            item.Id = Convert.ToInt64(row["Id"]);
            item.ShipmentDataId = Convert.ToInt64(row["ShipmentData_Id"]);
            item.ShipmentOrderId = row["ShipmentOrder_Id"] == DBNull.Value
                ? (long?)null
                : Convert.ToInt64(row["ShipmentOrder_Id"]);
            item.CartonId = row["CartonId"] == DBNull.Value ? null : Convert.ToString(row["CartonId"]);
            item.OrderNumber = row["OrderNumber"] == DBNull.Value ? null : Convert.ToString(row["OrderNumber"]);
            item.ShipStatus = Convert.ToString(row["ShipStatus"]);
            item.PrintStatus = Convert.ToString(row["PrintStatus"]);

            items.Add(item);
        }

        return items;
    }

    /// <summary>Prints one carton's label. Returns false when the loop must stop.</summary>
    private static bool PrintItem(ShipmentPrintItem item, long batchId)
    {
        // Nothing to print: the shipment failed or timed out. The carton still
        // occupies its place in the order, which is what keeps the sequence check
        // meaningful.
        if (item.ShipStatus == ShipStatus.Failed)
        {
            MarkPrintComplete(item, false);
            return true;
        }

        object zplValue = AutoShipDb.Scalar(
            "SELECT ZPL FROM dbo.ShipmentParcel WHERE Id = @Id;",
            AutoShipDb.P("@Id", item.Id));

        string zpl = zplValue == null ? null : Convert.ToString(zplValue);

        if (string.IsNullOrEmpty(zpl) || zpl.Trim().Length == 0)
        {
            // The shipment succeeded, but this carton has no label to show for it.
            // Treating this as printable would send an empty job and mark it done,
            // hiding the problem. On a multi-parcel shipment this is the carrier
            // returning fewer labels than we sent cartons.
            ShipLink.Helpers.LogHelper.LogMessage(
                "AutoShipStream: parcel " + item.Id + " (" + Describe(item) +
                ") belongs to a Shipped shipment but has no ZPL. Recorded as having no label; " +
                "that carton will go out unlabelled.");
            MarkPrintComplete(item, false);
            return true;
        }

        byte[] bytes = System.Text.Encoding.ASCII.GetBytes(zpl);
        string documentName = "ShipLink label " + Describe(item) + " (parcel " + item.Id + ")";

        // Stamped BEFORE the send, so a crash in the next few milliseconds leaves
        // evidence that this specific label was in flight.
        AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentParcel
                 SET PrintStatus = @Sending,
                     PrintSentAt = SYSUTCDATETIME()
               WHERE Id = @Id
                 AND PrintStatus = @Pending;",
            AutoShipDb.P("@Id", item.Id),
            AutoShipDb.P("@Sending", PrintStatus.Sending),
            AutoShipDb.P("@Pending", PrintStatus.Pending));

        RollUpShipmentPrintStatus(item.ShipmentDataId);

        try
        {
            string error = null;

            // Through the document queue, not straight to the printer. It serialises
            // every render and print in the process onto one STA thread, which is
            // what stops two shipments racing on the process-global default printer
            // and keeps this off the WPF dispatcher. Jobs leave in the order they
            // are submitted, so the order established above survives to the printer.
            ShipLink.Helpers.Reports.DocumentJobQueue.Instance.Run(
                documentName,
                delegate () { error = SendZpl(bytes, documentName); },
                TimeSpan.FromSeconds(AutoShipStreamConfig.PrintJobTimeoutSeconds));

            if (error != null)
            {
                MarkPrintFailed(item, error);
                Halt(batchId,
                    "Printer rejected parcel " + item.Id + " (" + Describe(item) + "): " + error);
                return false;
            }

            MarkPrintComplete(item, true);
            return true;
        }
        catch (TimeoutException)
        {
            // DocumentJobQueue abandons a timed-out job but LEAVES IT RUNNING, so
            // this label may still reach the printer minutes from now. Treating it
            // as "did not print" and moving on would produce a duplicate, or a label
            // out of order. The parcel stays in Sending and the loop stops.
            Halt(batchId,
                "Parcel " + item.Id + " (" + Describe(item) +
                ") did not complete within " + AutoShipStreamConfig.PrintJobTimeoutSeconds +
                "s. The job was abandoned but may still print. Confirm whether the label was produced " +
                "before resuming.");
            return false;
        }
        catch (Exception ex)
        {
            MarkPrintFailed(item, ex.Message);
            Halt(batchId,
                "Printing parcel " + item.Id + " (" + Describe(item) +
                ") failed:" + Environment.NewLine + ex.ToString());
            return false;
        }
    }

    /// <summary>
    /// Sends one label. Returns null on success, or a description of the failure.
    ///
    /// Already on the DocumentJobQueue worker by the time it gets here, so it does
    /// not need to serialise anything itself. RawPrinterHelper writes the Win32
    /// error code to the log on failure, which is the detail this return value
    /// cannot carry.
    /// </summary>
    private static string SendZpl(byte[] zpl, string documentName)
    {
        bool sent = ShipLink.Helpers.RawPrinterHelper.SendBytesArrayToPrinter(_printerName, zpl);

        if (sent)
        {
            return null;
        }

        return "Printer '" + _printerName + "' rejected " + documentName +
               ". See the preceding log entry for the Win32 error code.";
    }

    private static void MarkPrintComplete(ShipmentPrintItem item, bool printed)
    {
        AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentParcel
                 SET PrintStatus = @Status,
                     PrintedAt   = SYSUTCDATETIME()
               WHERE Id = @Id;",
            AutoShipDb.P("@Id", item.Id),
            AutoShipDb.P("@Status", printed ? PrintStatus.LabelPrinted : PrintStatus.NoLabel));

        RollUpShipmentPrintStatus(item.ShipmentDataId);
    }

    private static void MarkPrintFailed(ShipmentPrintItem item, string errorMessage)
    {
        AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentParcel
                 SET PrintStatus  = @Failed,
                     ErrorMessage = @Error
               WHERE Id = @Id;",
            AutoShipDb.P("@Id", item.Id),
            AutoShipDb.P("@Failed", PrintStatus.PrintFailed),
            AutoShipDb.P("@Error", AutoShipDb.Truncate(errorMessage, 1000)));

        RollUpShipmentPrintStatus(item.ShipmentDataId);
    }

    /// <summary>
    /// Recomputes a shipment's PrintStatus from its parcels.
    ///
    /// The shipment column is a roll-up and nothing else - it is never the source
    /// of truth, so it is recomputed from the parcels rather than nudged along,
    /// and a parcel changed behind this loop's back cannot leave the two
    /// disagreeing. It exists so the printing loop and the IX_ShipmentData_Unprinted
    /// index can skip a finished shipment without reading its cartons.
    /// </summary>
    private static void RollUpShipmentPrintStatus(long shipmentDataId)
    {
        AutoShipDb.Execute(
            @"UPDATE d
                 SET PrintStatus =
                     CASE
                       WHEN NOT EXISTS (SELECT 1 FROM dbo.ShipmentParcel p
                                         WHERE p.ShipmentData_Id = d.Id
                                           AND p.PrintStatus <> @Pending)      THEN @Pending
                       WHEN EXISTS (SELECT 1 FROM dbo.ShipmentParcel p
                                     WHERE p.ShipmentData_Id = d.Id
                                       AND p.PrintStatus IN (@Pending, @Sending)) THEN @Sending
                       WHEN EXISTS (SELECT 1 FROM dbo.ShipmentParcel p
                                     WHERE p.ShipmentData_Id = d.Id
                                       AND p.PrintStatus = @PrintFailed)       THEN @PrintFailed
                       WHEN EXISTS (SELECT 1 FROM dbo.ShipmentParcel p
                                     WHERE p.ShipmentData_Id = d.Id
                                       AND p.PrintStatus = @Printed)           THEN @Printed
                       ELSE @NoLabel
                     END
                FROM dbo.ShipmentData d
               WHERE d.Id = @Id;",
            AutoShipDb.P("@Id", shipmentDataId),
            AutoShipDb.P("@Pending", PrintStatus.Pending),
            AutoShipDb.P("@Sending", PrintStatus.Sending),
            AutoShipDb.P("@PrintFailed", PrintStatus.PrintFailed),
            AutoShipDb.P("@Printed", PrintStatus.LabelPrinted),
            AutoShipDb.P("@NoLabel", PrintStatus.NoLabel));
    }

    /// <summary>
    /// Closes the batch once every carton has reached a terminal print state.
    /// Guarded by the table rather than by what this loop believes it printed, so a
    /// parcel reverted behind its back cannot close a batch early.
    /// </summary>
    private static void CompleteBatch(long batchId)
    {
        int affected = AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentBatch
                 SET Status           = @Done,
                     PrintCompletedAt = SYSUTCDATETIME()
               WHERE Id = @Id
                 AND Status = @Printing
                 AND NOT EXISTS (
                       SELECT 1
                         FROM dbo.ShipmentParcel p
                         JOIN dbo.ShipmentData d ON d.Id = p.ShipmentData_Id
                        WHERE d.ShipmentBatch_Id = @Id
                          AND p.PrintStatus <> @Printed
                          AND p.PrintStatus <> @NoLabel
                     );",
            AutoShipDb.P("@Id", batchId),
            AutoShipDb.P("@Done", BatchStatus.LabelPrinted),
            AutoShipDb.P("@Printing", BatchStatus.Printing),
            AutoShipDb.P("@Printed", PrintStatus.LabelPrinted),
            AutoShipDb.P("@NoLabel", PrintStatus.NoLabel));

        if (affected == 1)
        {
            ShipLink.Helpers.LogHelper.LogMessage("AutoShipStream: batch " + batchId + " fully printed.");
        }
    }

    private static void Halt(long batchId, string reason)
    {
        _halted = true;
        _haltReason = reason;

        AutoShipDb.Execute(
            @"UPDATE dbo.ShipmentBatch
                 SET Status       = @Failed,
                     ErrorMessage = @Error
               WHERE Id = @Id;",
            AutoShipDb.P("@Id", batchId),
            AutoShipDb.P("@Failed", BatchStatus.Failed),
            AutoShipDb.P("@Error", AutoShipDb.Truncate(reason, 4000)));

        ShipLink.Helpers.LogHelper.LogMessage(
            "AutoShipStream: PRINTING HALTED. " + reason + Environment.NewLine +
            "No further labels will print until an operator resolves this and calls " +
            "AutoShipStreamPrinting.Resume(). This is deliberate - continuing would print out of order.");
    }

    /// <summary>
    /// Releases work abandoned by a crash.
    ///
    /// ShipLink is a single instance, so at startup nothing can legitimately be in
    /// flight - which is what lets this be a plain sweep rather than a lease table
    /// with expiry.
    ///
    /// Shipments already claimed into a batch are NOT reset to Ready: a later batch
    /// would then contain a lower Id than an earlier one, which is precisely the
    /// out-of-order case the printing loop halts on. They keep their batch and the
    /// shipping code picks them up again.
    ///
    /// PrintStatus = 'Sending' is left alone on purpose. Those labels may or may not
    /// have physically come off the printer, and there is no way to tell from here.
    /// </summary>
    private static void RecoverOrphanedWork()
    {
        try
        {
            AutoShipDb.Execute(
                @"UPDATE dbo.ShipmentBatch
                     SET Status = @Processing
                   WHERE Status = @Printing;",
                AutoShipDb.P("@Processing", BatchStatus.Processing),
                AutoShipDb.P("@Printing", BatchStatus.Printing));

            AutoShipDb.Execute(
                @"UPDATE dbo.ShipmentData
                     SET ShipStatus = @Ready
                   WHERE ShipStatus = @Processing
                     AND ShipmentBatch_Id IS NULL;",
                AutoShipDb.P("@Ready", ShipStatus.Ready),
                AutoShipDb.P("@Processing", ShipStatus.Processing));

            DataTable ambiguous = AutoShipDb.Query(
                @"SELECT p.Id, p.ShipmentData_Id, p.CartonId
                    FROM dbo.ShipmentParcel p
                   WHERE p.PrintStatus = @Sending
                   ORDER BY p.Id;",
                AutoShipDb.P("@Sending", PrintStatus.Sending));

            if (ambiguous.Rows.Count > 0)
            {
                List<string> ids = new List<string>();
                foreach (DataRow row in ambiguous.Rows)
                {
                    ids.Add(Convert.ToString(row["Id"]) +
                            " (shipment " + Convert.ToString(row["ShipmentData_Id"]) +
                            ", carton " + Convert.ToString(row["CartonId"]) + ")");
                }

                ShipLink.Helpers.LogHelper.LogMessage(
                    "AutoShipStream: " + ambiguous.Rows.Count + " label(s) were interrupted mid-print by a " +
                    "previous shutdown and need checking before printing continues. ShipmentParcel ids: " +
                    string.Join(", ", ids.ToArray()) + ".");
            }
        }
        catch (Exception ex)
        {
            ShipLink.Helpers.LogHelper.LogMessage(
                "AutoShipStream: startup recovery failed. Work orphaned by the last shutdown may remain " +
                "stuck:" + Environment.NewLine + ex.ToString());
        }
    }

    /// <summary>
    /// Config stores the printer as "System Printers.&lt;queue name&gt;"; the
    /// spooler wants just the queue name.
    /// </summary>
    private static string ResolvePrinterName()
    {
        string configured = string.Empty;

        if (Config.SystemSetup != null
            && Config.SystemSetup.DeviceSetup != null
            && Config.SystemSetup.DeviceSetup.PrinterSetup != null)
        {
            configured = Config.SystemSetup.DeviceSetup.PrinterSetup.PrinterName;
        }

        if (configured == null)
        {
            return string.Empty;
        }

        configured = configured.Trim();

        if (configured.StartsWith(SystemPrinterPrefix, StringComparison.OrdinalIgnoreCase))
        {
            configured = configured.Substring(SystemPrinterPrefix.Length);
        }

        return configured;
    }

    /// <summary>
    /// How a carton is named in a log line and on the print job. Both parts
    /// matter: the order number is what a human on the pick line recognises, and
    /// the carton number is the only thing that distinguishes two labels of the
    /// same order.
    /// </summary>
    private static string Describe(ShipmentPrintItem item)
    {
        string order = item.OrderNumber == null ? "unknown order" : "order " + item.OrderNumber;
        string carton = item.CartonId == null ? "no carton" : "carton " + item.CartonId;

        return order + ", " + carton;
    }
}