Option Strict On
Option Explicit On

Imports System
Imports System.Collections.Generic
Imports System.Diagnostics
Imports Nexamas.UI.TextRendering
Imports SkiaSharp

Namespace Nexamas.UI.TextInput

    ' CONTRACT:
    ' MASTextParagraphLayout is a read-only visual layout of multiline text.
    '
    ' Responsibilities:
    ' - Convert logical document lines into wrapped visual lines.
    ' - Provide text-index <-> visual-line mapping.
    ' - Provide caret point and hit-test helpers.
    '
    ' It must not:
    ' - Mutate MASTextInputState.
    ' - Render directly.
    ' - Handle keyboard/mouse input.
    Friend NotInheritable Class MASTextParagraphLayout

        Friend Const LargeDocumentWindowingThreshold As Integer = 2048
        Friend Const LargeDocumentMaxRealizedLines As Integer = 96
        Private Const WindowOverscanLines As Integer = 8
        Private Const CaretNeighborLines As Integer = 2

        Private Sub New(text As String,
                        contentRect As SKRect,
                        lines As IReadOnlyList(Of MASTextWrappedLine),
                        lineHeight As Single,
                        totalHeight As Single,
                        wordWrap As Boolean,
                        firstRealizedLogicalLineIndex As Integer,
                        lastRealizedLogicalLineIndex As Integer,
                        totalLogicalLineCount As Integer,
                        isWindowed As Boolean)

            Me.Text = If(text, String.Empty)
            Me.ContentRect = contentRect
            Me.Lines = If(lines, New List(Of MASTextWrappedLine)())
            Me.LineHeight = Math.Max(1.0F, lineHeight)
            Me.TotalHeight = Math.Max(Me.LineHeight, totalHeight)
            Me.WordWrap = wordWrap
            Me.FirstRealizedLogicalLineIndex = Math.Max(0, firstRealizedLogicalLineIndex)
            Me.LastRealizedLogicalLineIndex = Math.Max(Me.FirstRealizedLogicalLineIndex, lastRealizedLogicalLineIndex)
            Me.TotalLogicalLineCount = Math.Max(0, totalLogicalLineCount)
            Me.IsWindowed = isWindowed

            Validate()
        End Sub

        Friend ReadOnly Property Text As String
        Friend ReadOnly Property ContentRect As SKRect
        Friend ReadOnly Property Lines As IReadOnlyList(Of MASTextWrappedLine)
        Friend ReadOnly Property LineHeight As Single
        Friend ReadOnly Property TotalHeight As Single
        Friend ReadOnly Property WordWrap As Boolean
        Friend ReadOnly Property FirstRealizedLogicalLineIndex As Integer
        Friend ReadOnly Property LastRealizedLogicalLineIndex As Integer
        Friend ReadOnly Property TotalLogicalLineCount As Integer
        Friend ReadOnly Property IsWindowed As Boolean

        Friend ReadOnly Property RealizedLineCount As Integer
            Get
                Return Lines.Count
            End Get
        End Property

        Friend ReadOnly Property LineCount As Integer
            Get
                Return Lines.Count
            End Get
        End Property

        Friend Shared Function Create(document As MASTextMultilineDocument,
                                      contentR As SKRect,
                                      paint As SKPaint,
                                      verticalScrollOffsetPx As Single,
                                      wordWrap As Boolean,
                                      horizontalScrollOffsetPx As Single,
                                      Optional crispRendering As Boolean = True,
                                      Optional caretIndex As Integer = -1) As MASTextParagraphLayout

            Dim safeDocument As MASTextMultilineDocument = document
            If safeDocument Is Nothing Then
                safeDocument = MASTextMultilineDocument.FromText(String.Empty)
            End If

            Dim wrapped As New List(Of MASTextWrappedLine)()
            Dim lineHeight As Single = ComputeLineHeight(paint)
            Dim normalizedVerticalScroll As Single = NormalizeScroll(verticalScrollOffsetPx)

            If ShouldUseWindowedLayout(safeDocument, contentR, lineHeight) Then
                Return CreateWindowedLayout(
                    safeDocument:=safeDocument,
                    contentR:=contentR,
                    paint:=paint,
                    lineHeight:=lineHeight,
                    normalizedVerticalScroll:=normalizedVerticalScroll,
                    wordWrap:=wordWrap,
                    horizontalScrollOffsetPx:=horizontalScrollOffsetPx,
                    crispRendering:=crispRendering,
                    caretIndex:=caretIndex)
            End If

            Dim wrappedLineIndex As Integer = 0

            For logicalIndex As Integer = 0 To safeDocument.LineCount - 1
                Dim logicalLine As MASTextLineRange = safeDocument.GetLine(logicalIndex)
                Dim logicalText As String = logicalLine.GetText(safeDocument.Text)

                Dim segments As IReadOnlyList(Of MASTextWrapSegment) =
                    MASTextLayoutMeasurementGateway.BuildWrapSegments(logicalText, contentR.Width, paint, wordWrap)

                For Each segment As MASTextWrapSegment In segments
                    Dim globalStart As Integer = logicalLine.StartIndex + segment.Start
                    Dim segmentText As String = MASTextLayoutMeasurementGateway.SafeSubstring(logicalText, segment.Start, segment.Length)

                    Dim top As Single = contentR.Top + (wrappedLineIndex * lineHeight) - normalizedVerticalScroll
                    Dim bounds As New SKRect(contentR.Left, top, contentR.Right, top + lineHeight)

                    Dim baselineY As Single = ComputeBaseline(bounds, paint)
                    Dim isRtl As Boolean = ResolveIsRtl(segmentText)
                    Dim scrollX As Single = If(wordWrap, 0.0F, horizontalScrollOffsetPx)

                    Dim inlineLayout As MASTextInlineLayout =
                        MASTextInlineLayout.Create(
                            contentR:=bounds,
                            paint:=paint,
                            scrollOffsetPx:=scrollX,
                            displayText:=segmentText,
                            isRtl:=isRtl,
                            crispRendering:=crispRendering)

                    wrapped.Add(New MASTextWrappedLine(
                        wrappedLineIndex:=wrappedLineIndex,
                        logicalLineIndex:=logicalIndex,
                        startIndex:=globalStart,
                        length:=segment.Length,
                        text:=segmentText,
                        bounds:=bounds,
                        baselineY:=baselineY,
                        isRtl:=isRtl,
                        inlineLayout:=inlineLayout))

                    wrappedLineIndex += 1
                Next
            Next

            If wrapped.Count = 0 Then
                Dim bounds As New SKRect(contentR.Left, contentR.Top - normalizedVerticalScroll, contentR.Right, contentR.Top + lineHeight - normalizedVerticalScroll)
                Dim inlineLayout As MASTextInlineLayout =
                    MASTextInlineLayout.Create(bounds, paint, 0.0F, String.Empty, False, crispRendering)

                wrapped.Add(New MASTextWrappedLine(0, 0, 0, 0, String.Empty, bounds, ComputeBaseline(bounds, paint), False, inlineLayout))
            End If

            Dim totalHeight As Single = Math.Max(lineHeight, wrapped.Count * lineHeight)

            Return New MASTextParagraphLayout(
                text:=safeDocument.Text,
                contentRect:=contentR,
                lines:=wrapped,
                lineHeight:=lineHeight,
                totalHeight:=totalHeight,
                wordWrap:=wordWrap,
                firstRealizedLogicalLineIndex:=0,
                lastRealizedLogicalLineIndex:=Math.Max(0, safeDocument.LineCount - 1),
                totalLogicalLineCount:=safeDocument.LineCount,
                isWindowed:=False)
        End Function

        Private Shared Function ShouldUseWindowedLayout(document As MASTextMultilineDocument,
                                                        contentR As SKRect,
                                                        lineHeight As Single) As Boolean
            If document Is Nothing Then Return False
            If document.LineCount < LargeDocumentWindowingThreshold Then Return False
            If contentR.Height <= 0.0F OrElse lineHeight <= 0.0F Then Return False
            Return True
        End Function

        Private Shared Function CreateWindowedLayout(safeDocument As MASTextMultilineDocument,
                                                     contentR As SKRect,
                                                     paint As SKPaint,
                                                     lineHeight As Single,
                                                     normalizedVerticalScroll As Single,
                                                     wordWrap As Boolean,
                                                     horizontalScrollOffsetPx As Single,
                                                     crispRendering As Boolean,
                                                     caretIndex As Integer) As MASTextParagraphLayout

            Dim logicalCount As Integer = Math.Max(1, safeDocument.LineCount)
            Dim firstVisibleLogical As Integer = CInt(Math.Floor(normalizedVerticalScroll / Math.Max(1.0F, lineHeight))) - WindowOverscanLines
            Dim visibleLogicalCount As Integer = CInt(Math.Ceiling(Math.Max(1.0F, contentR.Height) / Math.Max(1.0F, lineHeight))) + (WindowOverscanLines * 2)

            If firstVisibleLogical < 0 Then firstVisibleLogical = 0
            If firstVisibleLogical >= logicalCount Then firstVisibleLogical = logicalCount - 1

            Dim lastVisibleLogical As Integer = Math.Min(logicalCount - 1, firstVisibleLogical + Math.Max(1, visibleLogicalCount) - 1)
            Dim caretLogical As Integer = -1
            If caretIndex >= 0 Then
                caretLogical = safeDocument.GetLineIndexFromTextIndex(caretIndex)
            End If

            Dim selectedLogicalLines As List(Of Integer) = BuildWindowLogicalLineSet(firstVisibleLogical, lastVisibleLogical, caretLogical, logicalCount)
            Dim wrapped As New List(Of MASTextWrappedLine)(Math.Min(LargeDocumentMaxRealizedLines, selectedLogicalLines.Count + 4))
            Dim firstRealized As Integer = If(selectedLogicalLines.Count = 0, 0, selectedLogicalLines(0))
            Dim lastRealized As Integer = firstRealized

            For Each logicalIndex As Integer In selectedLogicalLines
                If wrapped.Count >= LargeDocumentMaxRealizedLines Then Exit For

                Dim logicalLine As MASTextLineRange = safeDocument.GetLine(logicalIndex)
                Dim logicalText As String = logicalLine.GetText(safeDocument.Text)
                Dim segments As IReadOnlyList(Of MASTextWrapSegment) =
                    MASTextLayoutMeasurementGateway.BuildWrapSegments(logicalText, contentR.Width, paint, wordWrap)

                If segments Is Nothing OrElse segments.Count = 0 Then Continue For

                For Each segment As MASTextWrapSegment In segments
                    If wrapped.Count >= LargeDocumentMaxRealizedLines Then Exit For

                    Dim globalStart As Integer = logicalLine.StartIndex + segment.Start
                    Dim segmentText As String = MASTextLayoutMeasurementGateway.SafeSubstring(logicalText, segment.Start, segment.Length)
                    Dim estimatedVisualIndex As Integer = logicalIndex
                    Dim top As Single = contentR.Top + (estimatedVisualIndex * lineHeight) - normalizedVerticalScroll + ((wrapped.Count Mod Math.Max(1, segments.Count)) * lineHeight)
                    Dim bounds As New SKRect(contentR.Left, top, contentR.Right, top + lineHeight)
                    Dim baselineY As Single = ComputeBaseline(bounds, paint)
                    Dim isRtl As Boolean = ResolveIsRtl(segmentText)
                    Dim scrollX As Single = If(wordWrap, 0.0F, horizontalScrollOffsetPx)

                    Dim inlineLayout As MASTextInlineLayout =
                        MASTextInlineLayout.Create(
                            contentR:=bounds,
                            paint:=paint,
                            scrollOffsetPx:=scrollX,
                            displayText:=segmentText,
                            isRtl:=isRtl,
                            crispRendering:=crispRendering)

                    wrapped.Add(New MASTextWrappedLine(
                        wrappedLineIndex:=wrapped.Count,
                        logicalLineIndex:=logicalIndex,
                        startIndex:=globalStart,
                        length:=segment.Length,
                        text:=segmentText,
                        bounds:=bounds,
                        baselineY:=baselineY,
                        isRtl:=isRtl,
                        inlineLayout:=inlineLayout))

                    lastRealized = logicalIndex
                Next
            Next

            If wrapped.Count = 0 Then
                Dim bounds As New SKRect(contentR.Left, contentR.Top - normalizedVerticalScroll, contentR.Right, contentR.Top + lineHeight - normalizedVerticalScroll)
                Dim inlineLayout As MASTextInlineLayout =
                    MASTextInlineLayout.Create(bounds, paint, 0.0F, String.Empty, False, crispRendering)

                wrapped.Add(New MASTextWrappedLine(0, 0, 0, 0, String.Empty, bounds, ComputeBaseline(bounds, paint), False, inlineLayout))
                firstRealized = 0
                lastRealized = 0
            End If

            Dim totalHeight As Single = Math.Max(lineHeight, CSng(logicalCount) * lineHeight)

            Return New MASTextParagraphLayout(
                text:=safeDocument.Text,
                contentRect:=contentR,
                lines:=wrapped,
                lineHeight:=lineHeight,
                totalHeight:=totalHeight,
                wordWrap:=wordWrap,
                firstRealizedLogicalLineIndex:=firstRealized,
                lastRealizedLogicalLineIndex:=lastRealized,
                totalLogicalLineCount:=logicalCount,
                isWindowed:=True)
        End Function

        Private Shared Function BuildWindowLogicalLineSet(firstVisibleLogical As Integer,
                                                          lastVisibleLogical As Integer,
                                                          caretLogical As Integer,
                                                          logicalCount As Integer) As List(Of Integer)
            Dim selected As New SortedSet(Of Integer)()

            AddLogicalRange(selected, firstVisibleLogical, lastVisibleLogical, logicalCount)

            If caretLogical >= 0 Then
                AddLogicalRange(selected,
                                caretLogical - CaretNeighborLines,
                                caretLogical + CaretNeighborLines,
                                logicalCount)
            End If

            Return New List(Of Integer)(selected)
        End Function

        Private Shared Sub AddLogicalRange(selected As SortedSet(Of Integer),
                                           first As Integer,
                                           last As Integer,
                                           logicalCount As Integer)
            If selected Is Nothing OrElse logicalCount <= 0 Then Return
            If first < 0 Then first = 0
            If last >= logicalCount Then last = logicalCount - 1
            If last < first Then Return

            For i As Integer = first To last
                selected.Add(i)
            Next
        End Sub

        Friend Function GetLineFromTextIndex(textIndex As Integer) As MASTextWrappedLine
            If Lines.Count = 0 Then Return Nothing

            Dim index As Integer = ClampTextIndex(textIndex)

            For i As Integer = 0 To Lines.Count - 1
                Dim line As MASTextWrappedLine = Lines(i)
                If line Is Nothing Then Continue For

                If line.ContainsTextIndex(index) Then
                    Return line
                End If
            Next

            If index <= 0 Then Return Lines(0)
            Return Lines(Lines.Count - 1)
        End Function

        Friend Function GetLineFromPoint(ptPx As SKPoint) As MASTextWrappedLine
            If Lines.Count = 0 Then Return Nothing

            Dim best As MASTextWrappedLine = Lines(0)
            Dim bestDistance As Single = Math.Abs(ptPx.Y - best.Bounds.MidY)

            For i As Integer = 1 To Lines.Count - 1
                Dim line As MASTextWrappedLine = Lines(i)
                If line Is Nothing Then Continue For

                If ptPx.Y >= line.Bounds.Top AndAlso ptPx.Y <= line.Bounds.Bottom Then
                    Return line
                End If

                Dim distance As Single = Math.Abs(ptPx.Y - line.Bounds.MidY)
                If distance < bestDistance Then
                    bestDistance = distance
                    best = line
                End If
            Next

            Return best
        End Function

        Friend Function GetCaretPoint(textIndex As Integer) As SKPoint
            Dim line As MASTextWrappedLine = GetLineFromTextIndex(textIndex)
            If line Is Nothing OrElse line.InlineLayout Is Nothing Then
                Return New SKPoint(ContentRect.Left, ContentRect.Top)
            End If

            Dim localIndex As Integer = line.ToLocalIndex(textIndex)
            Dim x As Single = line.InlineLayout.GetCaretX(localIndex)
            Return New SKPoint(x, line.BaselineY)
        End Function

        Friend Function GetIndexFromPoint(ptPx As SKPoint) As Integer
            Dim line As MASTextWrappedLine = GetLineFromPoint(ptPx)
            If line Is Nothing OrElse line.InlineLayout Is Nothing Then Return 0

            Dim localIndex As Integer = line.InlineLayout.GetIndexFromX(ptPx.X)
            Return line.ToGlobalIndex(localIndex)
        End Function

        Friend Function ClampTextIndex(index As Integer) As Integer
            If index < 0 Then Return 0
            If index > Text.Length Then Return Text.Length
            Return index
        End Function

        Private Shared Function ComputeLineHeight(paint As SKPaint) As Single
            Return MASTextLayoutMeasurementGateway.ComputeParagraphLineHeight(paint)
        End Function

        Private Shared Function ComputeBaseline(bounds As SKRect,
                                                paint As SKPaint) As Single
            If paint Is Nothing Then Return bounds.MidY

            Return MASTextMetrics.ResolveCenteredBaselineY(bounds, paint)
        End Function

        Private Shared Function ResolveIsRtl(text As String) As Boolean
            Try
                Return MASShapedText.IsRtl(If(text, String.Empty))
            Catch masCaughtException1 As Exception
                Nexamas.UI.Diagnostics.MASExceptionSilencer.SwallowTextShapingFallback(masCaughtException1)
                Return False
            End Try
        End Function

        Private Shared Function NormalizeScroll(value As Single) As Single
            If Single.IsNaN(value) OrElse Single.IsInfinity(value) Then Return 0.0F
            If Math.Abs(value) < 0.01F Then Return 0.0F
            Return value
        End Function

        <Conditional("DEBUG")>
        Private Sub Validate()
            Debug.Assert(Text IsNot Nothing, "ParagraphLayout contract failed: Text is Nothing.")
            Debug.Assert(Lines IsNot Nothing, "ParagraphLayout contract failed: Lines is Nothing.")
            Debug.Assert(LineHeight > 0.0F, "ParagraphLayout contract failed: LineHeight must be positive.")
            Debug.Assert(TotalHeight > 0.0F, "ParagraphLayout contract failed: TotalHeight must be positive.")

            If Lines Is Nothing Then Return
            Debug.Assert(Lines.Count > 0, "ParagraphLayout contract failed: at least one wrapped line is required.")

            For i As Integer = 0 To Lines.Count - 1
                Dim line As MASTextWrappedLine = Lines(i)
                Debug.Assert(line IsNot Nothing, "ParagraphLayout contract failed: wrapped line is Nothing.")
                If line Is Nothing Then Continue For

                Debug.Assert(line.WrappedLineIndex = i, "ParagraphLayout contract failed: wrapped line index mismatch.")
                Debug.Assert(line.StartIndex >= 0, "ParagraphLayout contract failed: line StartIndex invalid.")
                Debug.Assert(line.EndIndexExclusive <= Text.Length, "ParagraphLayout contract failed: line exceeds text length.")
                Debug.Assert(line.InlineLayout IsNot Nothing, "ParagraphLayout contract failed: InlineLayout is Nothing.")
            Next
        End Sub

    End Class

End Namespace
