-- SPDX-FileCopyrightText: 2026 Alexandra de Wit
--
-- SPDX-License-Identifier: MIT

-- | Join supported PyPI fields while preserving source positions and existing read limits.
module Ecluse.Core.Registry.PyPI.StreamingProjection (
    PyPIProjection,
    emptyProjection,
    collectField,
    keepsFile,
    finishProjection,
) where

import Data.Aeson (Value)
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (parseEither)
import Data.Set qualified as Set

import Ecluse.Core.Package (InvalidEntry, InvalidEntryKind (InvalidVersionListing), PackageInfo, PackageName, mkInvalidEntry)
import Ecluse.Core.Package.Entry (EntryKey)
import Ecluse.Core.Registry.Metadata (MetadataError (MetadataBoundExceeded, MetadataUndecodable))
import Ecluse.Core.Registry.Metadata.Projection (projectionResult, validateReportedName)
import Ecluse.Core.Registry.PyPI.Document (SimpleDocument, simpleDocument)
import Ecluse.Core.Registry.PyPI.Project (FileCoordinate (fcVersionKey), FilenameMemo, filenameMemo, projectName, projectSimpleIndex, readCoordinate)
import Ecluse.Core.Registry.PyPI.Streaming (PyPIField (..), PyPIRead (..))
import Ecluse.Core.Registry.PyPI.Wire (IndexFile (ifEntryKey, ifFilename), checkApiVersion, decodeIndexFiles)
import Ecluse.Core.Registry.WireSupport (checkNameAgreement)
import Ecluse.Core.Security (LimitError (TooManyArtifacts), Limits (maxArtifactCount), checkVersionCountOf)

-- | Parsed files keep their read coordinate and share retained scalars with the compact serving records.
data PyPIProjection = PyPIProjection
    { PyPIProjection -> KeyMap Value
projectedEnvelope :: KeyMap.KeyMap Value
    , PyPIProjection -> [(IndexFile, Maybe FileCoordinate, Value)]
projectedFiles :: [(IndexFile, Maybe FileCoordinate, Value)]
    , PyPIProjection -> FilenameMemo
projectedMemo :: FilenameMemo
    , PyPIProjection -> [InvalidEntry]
projectedFileDrops :: [InvalidEntry]
    , PyPIProjection -> [InvalidEntry]
projectedVersionDrops :: [InvalidEntry]
    , PyPIProjection -> Set Text
projectedVersions :: Set Text
    , PyPIProjection -> Int
projectedArtifactCount :: Int
    , PyPIProjection -> Maybe LimitError
projectedBound :: Maybe LimitError
    , PyPIProjection -> Bool
projectedShape :: Bool
    , PyPIProjection -> Bool
projectedFilesSeen :: Bool
    , PyPIProjection -> Bool
projectedFilesActive :: Bool
    , PyPIProjection -> Bool
projectedVersionsSeen :: Bool
    , PyPIProjection -> Bool
projectedVersionsActive :: Bool
    }

-- | Start one project's source without retaining any input chunks.
emptyProjection :: PackageName -> PyPIProjection
emptyProjection :: PackageName -> PyPIProjection
emptyProjection PackageName
name = KeyMap Value
-> [(IndexFile, Maybe FileCoordinate, Value)]
-> FilenameMemo
-> [InvalidEntry]
-> [InvalidEntry]
-> Set Text
-> Int
-> Maybe LimitError
-> Bool
-> Bool
-> Bool
-> Bool
-> Bool
-> PyPIProjection
PyPIProjection KeyMap Value
forall a. Monoid a => a
mempty [] (PackageName -> FilenameMemo
filenameMemo PackageName
name) [] [] Set Text
forall a. Monoid a => a
mempty Int
0 Maybe LimitError
forall a. Maybe a
Nothing Bool
True Bool
False Bool
False Bool
False Bool
False

-- | Whether a file read now would be retained: files in the first files array, until a limit trips.
keepsFile :: PyPIProjection -> Bool
keepsFile :: PyPIProjection -> Bool
keepsFile PyPIProjection
acc = PyPIProjection -> Bool
projectedFilesActive PyPIProjection
acc Bool -> Bool -> Bool
&& Maybe LimitError -> Bool
forall a. Maybe a -> Bool
isNothing (PyPIProjection -> Maybe LimitError
projectedBound PyPIProjection
acc)

-- | Decode one compact file and stop retaining payloads after an existing structural limit trips.
collectField :: Limits -> PyPIRead -> PyPIProjection -> PyPIField -> Either LimitError PyPIProjection
collectField :: Limits
-> PyPIRead
-> PyPIProjection
-> PyPIField
-> Either LimitError PyPIProjection
collectField Limits
limits PyPIRead
mode PyPIProjection
acc =
    PyPIProjection -> Either LimitError PyPIProjection
forall a b. b -> Either a b
Right (PyPIProjection -> Either LimitError PyPIProjection)
-> (PyPIField -> PyPIProjection)
-> PyPIField
-> Either LimitError PyPIProjection
forall b c a. (b -> c) -> (a -> b) -> a -> c
. \case
        PyPIField
IgnoredField -> PyPIProjection
acc
        EnvelopeField Text
key Value
value
            | Key -> KeyMap Value -> Bool
forall a. Key -> KeyMap a -> Bool
KeyMap.member (Text -> Key
Key.fromText Text
key) (PyPIProjection -> KeyMap Value
projectedEnvelope PyPIProjection
acc) -> PyPIProjection
acc
            | Bool
otherwise -> PyPIProjection
acc{projectedEnvelope = KeyMap.insert (Key.fromText key) value (projectedEnvelope acc)}
        FilesShape Bool
valid -> PyPIProjection
acc{projectedFilesSeen = True, projectedFilesActive = not (projectedFilesSeen acc), projectedShape = projectedShape acc && (projectedFilesSeen acc || valid)}
        VersionsShape Bool
valid -> PyPIProjection
acc{projectedVersionsSeen = True, projectedVersionsActive = not (projectedVersionsSeen acc), projectedShape = projectedShape acc && (projectedVersionsSeen acc || valid)}
        InvalidVersionField Int
position Value
value
            | PyPIProjection -> Bool
projectedVersionsActive PyPIProjection
acc Bool -> Bool -> Bool
&& Maybe LimitError -> Bool
forall a. Maybe a -> Bool
isNothing (PyPIProjection -> Maybe LimitError
projectedBound PyPIProjection
acc) -> PyPIProjection
acc{projectedVersionDrops = mkInvalidEntry InvalidVersionListing (show position) value "expected a version string" : projectedVersionDrops acc}
            | Bool
otherwise -> PyPIProjection
acc
        FileField Int
position Maybe Value
raw
            | PyPIProjection -> Bool
projectedFilesActive PyPIProjection
acc -> Int -> Maybe Value -> PyPIProjection
collectFile Int
position Maybe Value
raw
            | Bool
otherwise -> PyPIProjection
acc
  where
    collectFile :: Int -> Maybe Value -> PyPIProjection
collectFile Int
position Maybe Value
raw =
        let count :: Int
count = Int
position Int -> Int -> Int
forall a. Num a => a -> a -> a
+ Int
1
            bounded :: PyPIProjection
bounded = case PyPIRead
mode of
                PyPIRead
FullRead -> PyPIProjection
acc
                SelectedRead{} -> Either LimitError () -> PyPIProjection -> PyPIProjection
forall {b}. Either LimitError b -> PyPIProjection -> PyPIProjection
withBound (Limits -> Int -> Either LimitError ()
checkVersionCountOf Limits
limits Int
count) PyPIProjection
acc
         in case Maybe Value
raw of
                Just Value
value | Maybe LimitError -> Bool
forall a. Maybe a -> Bool
isNothing (PyPIProjection -> Maybe LimitError
projectedBound PyPIProjection
bounded) Bool -> Bool -> Bool
|| PyPIRead
mode PyPIRead -> PyPIRead -> Bool
forall a. Eq a => a -> a -> Bool
== PyPIRead
FullRead -> Int -> Value -> PyPIProjection -> PyPIProjection
retainFile Int
position Value
value PyPIProjection
bounded
                Maybe Value
_ -> PyPIProjection
bounded
    retainFile :: Int -> Value -> PyPIProjection -> PyPIProjection
retainFile Int
position Value
value PyPIProjection
current = (PyPIProjection -> IndexFile -> PyPIProjection)
-> PyPIProjection -> [IndexFile] -> PyPIProjection
forall b a. (b -> a -> b) -> b -> [a] -> b
forall (t :: * -> *) b a.
Foldable t =>
(b -> a -> b) -> b -> t a -> b
foldl' (Value -> PyPIProjection -> IndexFile -> PyPIProjection
retain Value
value) PyPIProjection
withDrops [IndexFile]
files
      where
        ([IndexFile]
files, [InvalidEntry]
drops) = [(Int, Value)] -> ([IndexFile], [InvalidEntry])
decodeIndexFiles [(Int
position, Value
value)]
        withDrops :: PyPIProjection
withDrops
            | Maybe LimitError -> Bool
forall a. Maybe a -> Bool
isNothing (PyPIProjection -> Maybe LimitError
projectedBound PyPIProjection
current) = PyPIProjection
current{projectedFileDrops = reverse drops <> projectedFileDrops current}
            | Bool
otherwise = PyPIProjection
current
    retain :: Value -> PyPIProjection -> IndexFile -> PyPIProjection
retain Value
value PyPIProjection
current IndexFile
file =
        let (Maybe FileCoordinate
coordinate, FilenameMemo
memo) = FilenameMemo -> Text -> (Maybe FileCoordinate, FilenameMemo)
readCoordinate (PyPIProjection -> FilenameMemo
projectedMemo PyPIProjection
current) (IndexFile -> Text
ifFilename IndexFile
file)
            next :: PyPIProjection
next
                | Maybe LimitError -> Bool
forall a. Maybe a -> Bool
isNothing (PyPIProjection -> Maybe LimitError
projectedBound PyPIProjection
current) = PyPIProjection
current{projectedFiles = (file, coordinate, value) : projectedFiles current, projectedMemo = memo}
                | Bool
otherwise = PyPIProjection
current
         in case (PyPIRead
mode, FileCoordinate -> Text
fcVersionKey (FileCoordinate -> Text) -> Maybe FileCoordinate -> Maybe Text
forall (f :: * -> *) a b. Functor f => (a -> b) -> f a -> f b
<$> Maybe FileCoordinate
coordinate) of
                (PyPIRead
FullRead, Just Text
version) ->
                    let versions :: Set Text
versions = Text -> Set Text -> Set Text
forall a. Ord a => a -> Set a -> Set a
Set.insert Text
version (PyPIProjection -> Set Text
projectedVersions PyPIProjection
current)
                        count :: Int
count = PyPIProjection -> Int
projectedArtifactCount PyPIProjection
current Int -> Int -> Int
forall a. Num a => a -> a -> a
+ Int
1
                        bounded :: PyPIProjection
bounded = case Limits -> Int -> Either LimitError ()
checkVersionCountOf Limits
limits (Set Text -> Int
forall a. Set a -> Int
Set.size Set Text
versions) of
                            Left LimitError
fault -> PyPIProjection
next{projectedBound = Just fault}
                            Right () -> PyPIProjection
next{projectedVersions = versions, projectedArtifactCount = count}
                     in Either LimitError () -> PyPIProjection -> PyPIProjection
forall {b}. Either LimitError b -> PyPIProjection -> PyPIProjection
withBound (if Int
count Int -> Int -> Bool
forall a. Ord a => a -> a -> Bool
> Limits -> Int
maxArtifactCount Limits
limits then LimitError -> Either LimitError ()
forall a b. a -> Either a b
Left (Int -> Int -> LimitError
TooManyArtifacts Int
count (Limits -> Int
maxArtifactCount Limits
limits)) else () -> Either LimitError ()
forall a b. b -> Either a b
Right ()) PyPIProjection
bounded
                (PyPIRead, Maybe Text)
_ -> PyPIProjection
next
    withBound :: Either LimitError b -> PyPIProjection -> PyPIProjection
withBound Either LimitError b
result PyPIProjection
current = PyPIProjection
current{projectedBound = projectedBound current <|> either Just (const Nothing) result}

-- | Check protocol and name before structural counts, then project the source's retained files.
finishProjection :: PackageName -> PyPIProjection -> Either MetadataError (PackageInfo, SimpleDocument)
finishProjection :: PackageName
-> PyPIProjection
-> Either MetadataError (PackageInfo, SimpleDocument)
finishProjection PackageName
requested PyPIProjection
acc = do
    (String -> MetadataError)
-> Either String () -> Either MetadataError ()
forall a b c. (a -> b) -> Either a c -> Either b c
forall (p :: * -> * -> *) a b c.
Bifunctor p =>
(a -> b) -> p a c -> p b c
first (MetadataError -> String -> MetadataError
forall a b. a -> b -> a
const MetadataError
MetadataUndecodable) ((KeyMap Value -> Parser ()) -> KeyMap Value -> Either String ()
forall a b. (a -> Parser b) -> a -> Either String b
parseEither KeyMap Value -> Parser ()
checkApiVersion (PyPIProjection -> KeyMap Value
projectedEnvelope PyPIProjection
acc))
    reported <- (Text -> Either ParseError PackageName)
-> Maybe Value -> Either MetadataError PackageName
validateReportedName Text -> Either ParseError PackageName
projectName (Key -> KeyMap Value -> Maybe Value
forall v. Key -> KeyMap v -> Maybe v
KeyMap.lookup Key
"name" (PyPIProjection -> KeyMap Value
projectedEnvelope PyPIProjection
acc))
    _ <- projectionResult (checkNameAgreement requested reported ())
    unless (projectedShape acc) (Left MetadataUndecodable)
    traverse_ (Left . MetadataBoundExceeded) (projectedBound acc)
    let invalid = [InvalidEntry] -> [InvalidEntry]
forall a. [a] -> [a]
reverse (PyPIProjection -> [InvalidEntry]
projectedFileDrops PyPIProjection
acc) [InvalidEntry] -> [InvalidEntry] -> [InvalidEntry]
forall a. Semigroup a => a -> a -> a
<> [InvalidEntry] -> [InvalidEntry]
forall a. [a] -> [a]
reverse (PyPIProjection -> [InvalidEntry]
projectedVersionDrops PyPIProjection
acc)
    pure
        ( projectSimpleIndex reported invalid [(file, coordinate) | (file, coordinate, _) <- reverse (projectedFiles acc)]
        , simpleDocument (projectedEnvelope acc) (servedFiles (projectedFiles acc))
        )

-- Restore source order with every key evaluated, so the served list holds no decoded file.
servedFiles :: [(IndexFile, Maybe FileCoordinate, Value)] -> [(EntryKey, Value)]
servedFiles :: [(IndexFile, Maybe FileCoordinate, Value)] -> [(EntryKey, Value)]
servedFiles = ([(EntryKey, Value)]
 -> (IndexFile, Maybe FileCoordinate, Value) -> [(EntryKey, Value)])
-> [(EntryKey, Value)]
-> [(IndexFile, Maybe FileCoordinate, Value)]
-> [(EntryKey, Value)]
forall b a. (b -> a -> b) -> b -> [a] -> b
forall (t :: * -> *) b a.
Foldable t =>
(b -> a -> b) -> b -> t a -> b
foldl' (\[(EntryKey, Value)]
served (IndexFile
file, Maybe FileCoordinate
_, Value
value) -> let !key :: EntryKey
key = IndexFile -> EntryKey
ifEntryKey IndexFile
file in (EntryKey
key, Value
value) (EntryKey, Value) -> [(EntryKey, Value)] -> [(EntryKey, Value)]
forall a. a -> [a] -> [a]
: [(EntryKey, Value)]
served) []