packages feed

xlsx-1.2.0: src/Codec/Xlsx/Parser/Stream.hs

{-# LANGUAGE ConstraintKinds            #-}
{-# LANGUAGE DeriveAnyClass             #-}
{-# LANGUAGE DeriveGeneric              #-}
{-# LANGUAGE DerivingStrategies         #-}
{-# LANGUAGE FlexibleContexts           #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase                 #-}
{-# LANGUAGE OverloadedStrings          #-}
{-# LANGUAGE PackageImports             #-}
{-# LANGUAGE RankNTypes                 #-}
{-# LANGUAGE RecordWildCards            #-}
{-# LANGUAGE ScopedTypeVariables        #-}
{-# LANGUAGE StrictData                 #-}
{-# LANGUAGE TemplateHaskell            #-}
{-# LANGUAGE TypeApplications           #-}
{-# LANGUAGE UndecidableInstances       #-}
{-# LANGUAGE PatternSynonyms #-}

-- |
-- Module      : Codex.Xlsx.Parser.Stream
-- Description : Stream parser for xlsx files
-- Copyright   :
--   (c) Adam, 2021
--   (c) Supercede, 2021
-- License     : MIT
-- Stability   : experimental
-- Portability : POSIX
--
-- Parse @.xlsx@ sheets in constant memory.
--
-- All actions on an xlsx file run inside the 'XlsxM' monad, and must
-- be run with 'runXlsxM'. XlsxM is not a monad transformer, a design
-- inherited from the "zip" package's ZipArchive monad.
--
-- Inside the XlsxM monad, you can stream 'SheetItem's (a row) from a
-- particular sheet, using 'readSheetByIndex', which is callback-based and tied to IO.
--
module Codec.Xlsx.Parser.Stream
  ( XlsxM
  , runXlsxM
  , WorkbookInfo(..)
  , SheetInfo(..)
  , wiSheets
  , getOrParseSharedStringss
  , getWorkbookInfo
  , CellRow
  , readSheet
  , readSheetIdentifier
  , countRowsInSheet
  , countRowsInSheetIdentifier
  , collectItems
  , collectItemsIdentifier
  -- ** Index
  , SheetIndex
  , makeIndex
  , makeIndexFromName
  -- ** Identifier
  , SheetIdentifier
  , getSheetIdentifier
  , makeIdentifierFromName
  -- ** SheetItem
  , SheetItem(..)
  , si_sheet_index
  , si_row
  -- ** Row
  , Row(..)
  , ri_row_index
  , ri_cell_row
  -- * Errors
  , SheetErrors(..)
  , AddCellErrors(..)
  , CoordinateErrors(..)
  , TypeError(..)
  , WorkbookError(..)
  ) where

import qualified "zip" Codec.Archive.Zip as Zip
import Codec.Xlsx.Types.Cell
import Codec.Xlsx.Types.Common
import Codec.Xlsx.Types.Internal (RefId (..))
import Codec.Xlsx.Types.Internal.Relationships (Relationship (..),
                                                Relationships (..))
import Conduit (PrimMonad, (.|))
import qualified Conduit as C
import qualified Data.Vector as V
import Codec.Xlsx.LensCompat

import Codec.Xlsx.Parser.Internal
import Control.Monad
import Control.Monad.Catch
import Control.Monad.Except
import Control.Monad.Reader
import Control.Monad.State.Strict
import Data.Bifunctor
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.Conduit (ConduitT)
import qualified Data.DList as DL
import Data.Foldable
import Data.IORef
import qualified Data.IntMap.Strict as IntMap
import qualified Data.Map.Strict as M
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy as LT
import qualified Data.Text.Lazy.Builder as TB
import qualified Data.Text.Read as Read
import Data.Traversable (for)
import Data.XML.Types
import GHC.Generics
import Codec.Xlsx.Parser.Internal.Memoize

import qualified Codec.Xlsx.Parser.Stream.HexpatInternal as HexpatInternal
import Control.Monad.Base
import Control.Monad.Trans.Control
import Text.XML.Expat.Internal.IO as Hexpat
import Text.XML.Expat.SAX as Hexpat
import Codec.Xlsx.Types
import Codec.Xlsx.Parser.Stream.SheetInfo
import Codec.Xlsx.Parser.Stream.Row
import Codec.Xlsx.Parser.Stream.SheetItem
import Codec.Xlsx.Parser.Stream.SheetIdentifier
import Codec.Xlsx.Parser.Stream.SheetIndex

type SharedStringsMap = V.Vector Text

-- | Type of the excel value
--
-- Note: Some values are untyped and rules of their type resolution are not known.
-- They may be treated simply as strings as well as they may be context-dependent.
-- By far we do not bother with it.
data ExcelValueType
  = TS      -- ^ shared string
  | TStr    -- ^ either an inline string ("inlineStr") or a formula string ("str")
  | TN      -- ^ number
  | TB      -- ^ boolean
  | TE      -- ^ excell error, the sheet can contain error values, for example if =1/0, causes division by zero
  | Untyped -- ^ Not all values have types
  deriving stock (Generic, Show)

-- | State for parsing sheets
data ParserState = MkParserState
  { _ps_row             :: ~CellRow        -- ^ Current row
  , _ps_cell_row_index  :: RowIndex        -- ^ Current row number
  , _ps_cell_col_index  :: ColumnIndex     -- ^ Current column number
  , _ps_cell_style      :: Maybe Int
  , _ps_is_in_val       :: Bool            -- ^ Flag for indexing wheter the parser is in value or not
  , _ps_shared_strings  :: SharedStringsMap -- ^ Shared string map
  , _ps_type            :: ExcelValueType  -- ^ The last detected value type

  , _ps_text_buf        :: Text
  -- ^ for hexpat only, which can break up char data into multiple events
  , _ps_worksheet_ended :: Bool
  -- ^ For hexpat only, which can throw errors right at the end of the sheet
  -- rather than ending gracefully.
  } deriving stock (Generic, Show)
makeLenses 'MkParserState

-- | State for parsing shared strings
data SharedStringsState = MkSharedStringsState
  { _ss_string :: TB.Builder -- ^ String we are parsing
  -- TODO: At the moment SharedStrings can be used only to create CellText values.
  -- We should add support for CellRich values.
  , _ss_list   :: DL.DList Text -- ^ list of shared strings
  } deriving stock (Generic, Show)
makeLenses 'MkSharedStringsState

type HasParserState = MonadState ParserState
type HasSharedStringsState = MonadState SharedStringsState

-- | Information about the workbook contained in xl/workbook.xml
-- (currently a subset)
data WorkbookInfo = WorkbookInfo
  { _wiSheets :: [SheetInfo]
  } deriving Show
makeLenses 'WorkbookInfo

data XlsxMState = MkXlsxMState
  { _xs_shared_strings :: Memoized (V.Vector Text)
  , _xs_workbook_info  :: Memoized WorkbookInfo
  , _xs_relationships  :: Memoized Relationships
  }

newtype XlsxM a = XlsxM {_unXlsxM :: ReaderT XlsxMState Zip.ZipArchive a}
  deriving newtype
    ( Functor,
      Applicative,
      Monad,
      MonadIO,
      MonadCatch,
      MonadMask,
      MonadThrow,
      MonadReader XlsxMState,
      MonadBase IO,
      MonadBaseControl IO
    )

-- | Initial parsing state
initialParserState :: ParserState
initialParserState = MkParserState
  { _ps_row             = mempty
  , _ps_cell_row_index  = 0
  , _ps_cell_col_index  = 0
  , _ps_is_in_val       = False
  , _ps_shared_strings  = mempty
  , _ps_type            = Untyped
  , _ps_text_buf        = mempty
  , _ps_worksheet_ended = False
  , _ps_cell_style      = Nothing
  }

-- | Initial parsing state
initialSharedStrings :: SharedStringsState
initialSharedStrings = MkSharedStringsState
  { _ss_string = mempty
  , _ss_list = mempty
  }

-- | Parse shared string entry from xml event and return it once
-- we've reached the end of given element
{-# SCC parseSharedStrings #-}
parseSharedStrings
  :: ( MonadThrow m
     , HasSharedStringsState m
     )
  => HexpatEvent -> m (Maybe Text)
parseSharedStrings = \case
  -- TODO: Add parsing of text styles to further create CellRich values.
  StartElement "si" _ -> Nothing <$ (ss_string .= mempty)
  EndElement "si"     -> Just . LT.toStrict . TB.toLazyText <$> gets _ss_string
  CharacterData txt   -> Nothing <$ (ss_string <>= TB.fromText txt)
  _                   -> pure Nothing

-- | Run a series of actions on an Xlsx file
runXlsxM :: MonadIO m => FilePath -> XlsxM a -> m a
runXlsxM xlsxFile (XlsxM act) = liftIO $ do
  -- TODO: don't run the withArchive multiple times but use liftWith or runInIO instead
  _xs_workbook_info  <- memoizeRef (Zip.withArchive xlsxFile readWorkbookInfo)
  _xs_relationships  <- memoizeRef (Zip.withArchive xlsxFile readWorkbookRelationships)
  _xs_shared_strings <- memoizeRef (Zip.withArchive xlsxFile parseSharedStringss)
  Zip.withArchive xlsxFile $ runReaderT act $ MkXlsxMState{..}

liftZip :: Zip.ZipArchive a -> XlsxM a
liftZip = XlsxM . ReaderT . const

parseSharedStringss :: Zip.ZipArchive (V.Vector Text)
parseSharedStringss = do
      sharedStrsSel <- Zip.mkEntrySelector "xl/sharedStrings.xml"
      hasSharedStrs <- Zip.doesEntryExist sharedStrsSel
      if not hasSharedStrs
        then pure mempty
        else do
          let state0 = initialSharedStrings
          byteSrc <- Zip.getEntrySource sharedStrsSel
          st <- liftIO $ runExpat state0 byteSrc $ \evs -> forM_ evs $ \ev -> do
            mTxt <- parseSharedStrings ev
            for_ mTxt $ \txt ->
              ss_list %= (`DL.snoc` txt)
          pure $ V.fromList $ DL.toList $ _ss_list st

{-# SCC getOrParseSharedStringss #-}
getOrParseSharedStringss :: XlsxM (V.Vector Text)
getOrParseSharedStringss = runMemoized =<< asks _xs_shared_strings

readWorkbookInfo :: Zip.ZipArchive WorkbookInfo
readWorkbookInfo = do
   sel <- Zip.mkEntrySelector "xl/workbook.xml"
   src <- Zip.getEntrySource sel
   sheetIndexRef <- liftIO $ newIORef 0
   sheets <- liftIO $ runExpat [] src $ \evs -> forM_ evs $ \case
     StartElement ("sheet" :: ByteString) attrs -> do
       nm <- lookupBy "name" attrs
       sheetId <- lookupBy "sheetId" attrs
       rId <- lookupBy "r:id" attrs
       sheetState <- case lookup "state" attrs of
         Nothing -> pure Visible -- default to visible if not found
         Just ss -> case ss of
           "visible"  -> pure Visible
           "hidden"   -> pure Hidden
           "veryHidden" -> pure VeryHidden
           _          -> throwM $ InvalidSheetState ss
       sheetNum <- either (throwM . ParseDecimalError sheetId) pure $ eitherDecimal sheetId
       sheetIndex <- liftIO $ atomicModifyIORef' sheetIndexRef $ \i -> (succ i, i)
       let info = MkSheetInfo nm (RefId rId) sheetNum sheetIndex sheetState
       modify' (info :)
     _ -> pure ()
   pure $ WorkbookInfo sheets

lookupBy :: MonadThrow m => ByteString -> [(ByteString, Text)] -> m Text
lookupBy fields attrs = maybe (throwM $ LookupError attrs fields) pure $ lookup fields attrs

-- | Returns information about the workbook, found in
-- xl/workbook.xml. The result is cached so the XML will only be
-- decompressed and parsed once inside a larger XlsxM action.
getWorkbookInfo :: XlsxM WorkbookInfo
getWorkbookInfo = runMemoized =<< asks _xs_workbook_info

readWorkbookRelationships :: Zip.ZipArchive Relationships
readWorkbookRelationships = do
   sel <- Zip.mkEntrySelector "xl/_rels/workbook.xml.rels"
   src <- Zip.getEntrySource sel
   liftIO $ fmap Relationships $ runExpat mempty src $ \evs -> forM_ evs $ \case
     StartElement ("Relationship" :: ByteString) attrs -> do
       rId <- lookupBy "Id" attrs
       rTarget <- lookupBy "Target" attrs
       rType <- lookupBy "Type" attrs
       modify' $ M.insert (RefId rId) $
         Relationship { relType = rType,
                        relTarget = T.unpack rTarget
                       }
     _ -> pure ()

-- | Gets relationships for the workbook (this means the filenames in
-- the relationships map are relative to "xl/" base path within the
-- zip file.
--
-- The relationships xml file will only be parsed once when called
-- multiple times within a larger XlsxM action.
getWorkbookRelationships :: XlsxM Relationships
getWorkbookRelationships = runMemoized =<< asks _xs_relationships

type HexpatEvent = SAXEvent ByteString Text

relIdToEntrySelector :: RefId -> XlsxM (Maybe Zip.EntrySelector)
relIdToEntrySelector rid = do
  Relationships rels <- getWorkbookRelationships
  for (M.lookup rid rels) $ \rel -> do
    Zip.mkEntrySelector $ "xl/" <> relTarget rel

-- If the given sheet number exists, returns Just a conduit source of the stream
-- of XML events in a particular sheet. Returns Nothing when the sheet doesn't
-- exist.
{-# SCC getSheetXmlSource #-}
getSheetXmlSource ::
  (PrimMonad m, MonadThrow m, C.MonadResource m) =>
  SheetIdentifier ->
  XlsxM (Maybe (ConduitT () ByteString m ()))
getSheetXmlSource (SheetIdentifier refId _sheetId) = do
  -- TODO: The Zip library may throw exceptions that aren't exposed from this
  -- module, so downstream library users would need to add the 'zip' package to
  -- handle them. Consider re-wrapping zip library exceptions, or just
  -- re-export them?
  mSheetSel <- relIdToEntrySelector refId
  sheetExists <- maybe (pure False) (liftZip . Zip.doesEntryExist) mSheetSel
  case mSheetSel of
    Just sheetSel
      | sheetExists ->
          Just <$> liftZip (Zip.getEntrySource sheetSel)
    _ -> pure Nothing

{-# SCC runExpat #-}
runExpat :: forall state tag text.
  (GenericXMLString tag, GenericXMLString text) =>
  state ->
  ConduitT () ByteString (C.ResourceT IO) () ->
  ([SAXEvent tag text] -> StateT state IO ()) ->
  IO state
runExpat initialState byteSource handler = do
  -- Set up state
  ref <- newIORef initialState
  -- Set up parser and callbacks
  (parseChunk, _getLoc) <- Hexpat.hexpatNewParser Nothing Nothing False
  let noExtra _ offset = pure ((), offset)
      {-# SCC processChunk #-}
      {-# INLINE processChunk #-}
      processChunk isFinalChunk chunk = do
        (buf, len, mError) <- parseChunk chunk isFinalChunk
        saxen <- HexpatInternal.parseBuf buf len noExtra
        case mError of
          Just err -> error $ "expat error: " <> show err
          Nothing -> do
            state0 <- liftIO $ readIORef ref
            state1 <-
              {-# SCC "runExpat_runStateT_call" #-}
              execStateT (handler $ map fst saxen) state0
            writeIORef ref state1
  C.runConduitRes $
    byteSource .|
    C.awaitForever (liftIO . processChunk False)
  processChunk True BS.empty
  readIORef ref

runExpatForSheet ::
  ParserState ->
  ConduitT () ByteString (C.ResourceT IO) () ->
  (Row -> IO ()) ->
  XlsxM ()
runExpatForSheet initState byteSource inner =
  void $ liftIO $ runExpat initState byteSource handler
  where
    handler evs = forM_ evs $ \ev -> do
      parseRes <- runExceptT $ matchHexpatEvent ev
      case parseRes of
        Left err -> throwM err
        Right (Just cellRow)
          | not (IntMap.null cellRow) -> do
              rowNum <- use ps_cell_row_index
              liftIO $ inner $ MkRow rowNum cellRow
        _ -> pure ()

-- | this will collect the sheetitems in a list.
--   useful for cases were memory is of no concern but a sheetitem
--   type in a list is needed.
{-# DEPRECATED collectItems "prefer to use collectItemsIdentifier, see issue #193" #-}
collectItems ::
  SheetIndex ->
  XlsxM [SheetItem]
collectItems (MkSheetIndex sheetId) = makeIdentifierFromId sheetId >>= \case
  Nothing -> pure []
  Just identifier -> fmap (MkSheetItem sheetId) <$> collectItemsIdentifier identifier

-- | this will collect the rows in a list.
--   useful for cases were memory is of no concern but a row
--   type in a list is needed.
collectItemsIdentifier ::
  SheetIdentifier ->
  XlsxM [Row]
collectItemsIdentifier sheetIdentifier = do
 res <- liftIO $ newIORef []
 void $ readSheetIdentifier sheetIdentifier $ \item ->
   liftIO (modifyIORef' res (item :))
 fmap reverse $ liftIO $ readIORef res

-- | Look up the index of a case insensitive sheet name
{-# DEPRECATED makeIndexFromName "prefer to use makeIdentifierFromName" #-}
makeIndexFromName :: Text -> XlsxM (Maybe SheetIndex)
makeIndexFromName sheetName = do
  fmap (makeIndex . siSheetId) <$> makeIdentifierFromName sheetName

-- | Look up the identifier of a case insensitive sheet name
makeIdentifierFromName :: Text -> XlsxM (Maybe SheetIdentifier)
makeIdentifierFromName sheetName = do
  wi <- getWorkbookInfo
  -- The Excel UI does not allow a user to create two sheets whose
  -- names differ only in alphabetic case (at least for ascii...)
  let sheetNameCI = T.toLower sheetName
      findRes :: Maybe SheetInfo
      findRes = find ((== sheetNameCI) . T.toLower . sheetInfoName) $ _wiSheets wi
  pure $ getSheetIdentifier <$> findRes

-- | Look up the identifier of a sheet id. Not for exporting since it is somewhat
-- nonsensical if you do not have the sheet id.
makeIdentifierFromId :: Int -> XlsxM (Maybe SheetIdentifier)
makeIdentifierFromId sheetId = do
  WorkbookInfo sheets <- getWorkbookInfo
  pure $ getSheetIdentifier <$> find ((== sheetId) . sheetInfoSheetId) sheets

{-# DEPRECATED readSheet "prefer to use readSheetIdentifier" #-}
readSheet ::
  SheetIndex ->
  -- | Function to consume the sheet's rows
  (SheetItem -> IO ()) ->
  -- | Returns False if sheet doesn't exist, or True otherwise
  XlsxM Bool
readSheet (MkSheetIndex sheetId) inner = makeIdentifierFromId sheetId >>= \case
    Nothing -> pure False
    Just identifier -> readSheetIdentifier identifier (inner . MkSheetItem sheetId)

readSheetIdentifier ::
  SheetIdentifier ->
  -- | Function to consume the sheet's rows
  (Row -> IO ()) ->
  -- | Returns False if sheet doesn't exist, or True otherwise
  XlsxM Bool
readSheetIdentifier identifier inner = do
  mSrc :: Maybe (ConduitT () ByteString (C.ResourceT IO) ()) <- getSheetXmlSource identifier
  let
  case mSrc of
    Nothing -> pure False
    Just sourceSheetXml -> do
      sharedStrs <- getOrParseSharedStringss
      let sheetState0 = initialParserState
            & ps_shared_strings .~ sharedStrs
      runExpatForSheet sheetState0 sourceSheetXml inner
      pure True

-- | Returns number of rows in the given sheet (identified by the
-- sheet's ID, AKA the sheetId attribute, AKA 'sheetInfoSheetId'), or Nothing
-- if the sheet does not exist. Does not perform a full parse of the
-- XML into 'SheetItem's, so it should be more efficient than counting
-- via 'readSheetByIndex'.
{-# DEPRECATED countRowsInSheet "prefer to use countRowsInSheetIdentifier" #-}
countRowsInSheet :: SheetIndex -> XlsxM (Maybe Int)
countRowsInSheet (MkSheetIndex sheetId) =
  makeIdentifierFromId sheetId >>= \case
    Nothing -> pure Nothing
    Just identifier -> countRowsInSheetIdentifier identifier

-- | Returns number of rows in the given sheet or Nothing
-- if the sheet does not exist. Does not perform a full parse of the
-- XML into 'SheetItem's, so it should be more efficient than counting
-- via 'readSheetByIndex'.
countRowsInSheetIdentifier :: SheetIdentifier -> XlsxM (Maybe Int)
countRowsInSheetIdentifier identifier = do
  mSrc :: Maybe (ConduitT () ByteString (C.ResourceT IO) ()) <-
    getSheetXmlSource identifier
  for mSrc $ \sourceSheetXml -> do
    liftIO $ runExpat @Int @ByteString @ByteString 0 sourceSheetXml $ \evs ->
      forM_ evs $ \case
        StartElement "row" _ -> modify' (+1)
        _                    -> pure ()

-- | Return row from the state and empty it
popRow :: HasParserState m => m CellRow
popRow = do
  row <- use ps_row
  ps_row .= mempty
  pure row

data AddCellErrors
  = ReadError -- ^ Could not read current cell value
      Text    -- ^ Original value
      String  -- ^ Error message
  | SharedStringsNotFound -- ^ Could not find string by index in shared string table
      Int                -- ^ Given index
      (V.Vector Text)      -- ^ Given shared strings to lookup in
  deriving Show

-- | Parse the given value
--
-- If it's a string, we try to get it our of a shared string table
{-# SCC parseValue #-}
parseValue :: SharedStringsMap -> Text -> ExcelValueType -> Either AddCellErrors CellValue
parseValue sstrings txt = \case
  TS -> do
    (idx, _) <- ReadError txt `first` Read.decimal @Int txt
    string <- maybe (Left $ SharedStringsNotFound idx sstrings) Right $ {-# SCC "sstrings_lookup_scc" #-}  (sstrings ^? ix idx)
    Right $ CellText string
  TStr -> pure $ CellText txt
  TN -> bimap (ReadError txt) (CellDouble . fst) $ Read.double txt
  TE -> bimap (ReadError txt) (CellError . fst) $ fromAttrVal txt
  TB | txt == "1" -> Right $ CellBool True
     | txt == "0" -> Right $ CellBool False
     | otherwise -> Left $ ReadError txt "Could not read Excel boolean value (expected 0 or 1)"
  Untyped -> Right (parseUntypedValue txt)

-- TODO: some of the cells are untyped and we need to test whether
-- they all are strings or something more complicated
parseUntypedValue :: Text -> CellValue
parseUntypedValue = CellText

-- | Adds a cell to row in state monad
{-# SCC addCellToRow #-}
addCellToRow
  :: ( MonadError SheetErrors m
     , HasParserState m
     )
  => Text -> m ()
addCellToRow txt = do
  st <- get
  style <- use ps_cell_style
  when (_ps_is_in_val st) $ do
    val <- liftEither $ first ParseCellError $ parseValue (_ps_shared_strings st) txt (_ps_type st)
    put $ st { _ps_row = IntMap.insert (unColumnIndex $ _ps_cell_col_index st)
                         (Cell { _cellStyle   = style
                               , _cellValue   = Just val
                               , _cellComment = Nothing
                               , _cellFormula = Nothing
                               }) $ _ps_row st}

data SheetErrors
  = ParseCoordinateError CoordinateErrors -- ^ Error while parsing coordinates
  | ParseTypeError TypeError              -- ^ Error while parsing types
  | ParseCellError AddCellErrors          -- ^ Error while parsing cells
  | ParseStyleErrors StyleError
  | HexpatParseError Hexpat.XMLParseError
  deriving stock Show
  deriving anyclass Exception

type SheetValue = (ByteString, Text)
type SheetValues = [SheetValue]

data CoordinateErrors
  = CoordinateNotFound SheetValues         -- ^ If the coordinate was not specified in "r" attribute
  | NoListElement SheetValue SheetValues   -- ^ If the value is empty for some reason
  | NoTextContent Content SheetValues      -- ^ If the value has something besides @ContentText@ inside
  | DecodeFailure Text SheetValues         -- ^ If malformed coordinate text was passed
  deriving stock Show
  deriving anyclass Exception

data TypeError
  = TypeNotFound SheetValues
  | TypeNoListElement SheetValue SheetValues
  | UnkownType Text SheetValues
  | TypeNoTextContent Content SheetValues
  deriving Show
  deriving anyclass Exception

data WorkbookError = LookupError { lookup_attrs :: [(ByteString, Text)], lookup_field :: ByteString }
                   | ParseDecimalError Text String
                   | InvalidSheetState Text
  deriving Show
  deriving anyclass Exception

{-# SCC matchHexpatEvent #-}
matchHexpatEvent ::
  ( MonadError SheetErrors m,
    HasParserState m
  ) =>
  HexpatEvent ->
  m (Maybe CellRow)
matchHexpatEvent ev = case ev of
  CharacterData txt -> {-# SCC "handle_CharData" #-} do
    inVal <- use ps_is_in_val
    when inVal $
      {-# SCC "append_text_buf" #-} (ps_text_buf <>= txt)
    pure Nothing
  StartElement "c" attrs -> Nothing <$ (setCoord attrs *> setType attrs *> setStyle attrs)
  StartElement "is" _ -> Nothing <$ (ps_is_in_val .= True)
  EndElement "is" -> Nothing <$ finaliseCellValue
  StartElement "v" _ -> Nothing <$ (ps_is_in_val .= True)
  EndElement "v" -> Nothing <$ finaliseCellValue
  -- If beginning of row, empty the state and return nothing.
  -- We don't know if there is anything in the state, the user may have
  -- decided to <row> <row> (not closing). In any case it's the beginning of a new row
  -- so we clear the state.
  StartElement "row" _ -> Nothing <$ popRow
  -- If at the end of the row, we have collected the whole row into
  -- the current state. Empty the state and return the row.
  EndElement "row" -> Just <$> popRow
  StartElement "worksheet" _ -> ps_worksheet_ended .= False >> pure Nothing
  EndElement "worksheet" -> ps_worksheet_ended .= True >> pure Nothing
  -- Skip everything else, e.g. the formula elements <f>
  FailDocument err -> do
    -- this event is emitted at the end the xml stream (possibly
    -- because the xml files in xlsx archives don't end in a
    -- newline, but that's a guess), so we use state to determine if
    -- it's expected.
    finished <- use ps_worksheet_ended
    unless finished $
      throwError $ HexpatParseError err
    pure Nothing
  _ -> pure Nothing

{-# INLINE finaliseCellValue #-}
finaliseCellValue ::
  ( MonadError SheetErrors m, HasParserState m ) => m ()
finaliseCellValue = do
  txt <- gets _ps_text_buf
  addCellToRow txt
  modify' $ \st ->
    st { _ps_is_in_val = False
       , _ps_text_buf = mempty
       }

-- | Update state coordinates accordingly to @parseCoordinates@
{-# SCC setCoord #-}
setCoord
  :: ( MonadError SheetErrors m
     , HasParserState m
     )
  => SheetValues -> m ()
setCoord list = do
  coordinates <- liftEither $ first ParseCoordinateError $ parseCoordinates list
  ps_cell_col_index .= (coordinates ^. _2)
  ps_cell_row_index .= (coordinates ^. _1)

-- | Parse type from values and update state accordingly
setType
  :: ( MonadError SheetErrors m
     , HasParserState m
 )
  => SheetValues -> m ()
setType list = do
  type' <- liftEither $ first ParseTypeError $ parseType list
  ps_type .= type'

-- | Find sheet value by its name
findName :: ByteString -> SheetValues -> Maybe SheetValue
findName name = find ((name ==) . fst)
{-# INLINE findName #-}

setStyle :: (MonadError SheetErrors m, HasParserState m) => SheetValues -> m ()
setStyle list = do
  style <- liftEither $ first ParseStyleErrors $ parseStyle list
  ps_cell_style .= style

data StyleError = InvalidStyleRef { seInput:: Text,  seErrorMsg :: String}
  deriving Show

parseStyle :: SheetValues -> Either StyleError (Maybe Int)
parseStyle list =
  case findName "s" list of
    Nothing -> pure Nothing
    Just (_nm, valTex) -> case Read.decimal valTex of
      Left err        -> Left (InvalidStyleRef valTex err)
      Right (i, _rem) -> pure $ Just i

-- | Parse value type
{-# SCC parseType #-}
parseType :: SheetValues -> Either TypeError ExcelValueType
parseType list =
  case findName "t" list of
    -- NB: According to format specification default value for cells without
    -- `t` attribute is a `n` - number.
    --
    -- <xsd:complexType name="CT_Cell" from spec (see the `CellValue` spec reference)>
    --  ..
    --  <xsd:attribute name="t" type="ST_CellType" use="optional" default="n"/>
    -- </xsd:complexType>
    Nothing -> Right TN
    Just (_nm, valText)->
      case valText of
        "n"         -> Right TN
        "s"         -> Right TS
         -- "Cell containing a formula string". Probably shouldn't be TStr..
        "str"       -> Right TStr
        "inlineStr" -> Right TStr
        "b"         -> Right TB
        "e"         -> Right TE
        other       -> Left $ UnkownType other list

-- | Parse coordinates from a list of xml elements if such were found on "r" key
{-# SCC parseCoordinates #-}
parseCoordinates :: SheetValues -> Either CoordinateErrors (RowIndex, ColumnIndex)
parseCoordinates list = do
  (_nm, valText) <- maybe (Left $ CoordinateNotFound list) Right $ findName "r" list
  maybe (Left $ DecodeFailure valText list) Right $ fromSingleCellRef $ CellRef valText