{-# LANGUAGE CPP #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- |
-- Module     : Data.Ini.Config.Bidir
-- Copyright  : (c) Getty Ritter, 2017
-- License    : BSD
-- Maintainer : Getty Ritter <config-ini@infinitenegativeutility.com>
-- Stability  : experimental
--
-- This module presents an alternate API for parsing INI files.  Unlike
-- the standard API, it is bidirectional: the same declarative structure
-- can be used to parse an INI file to a value, serialize an INI file
-- from a value, or even /update/ an INI file by comparing it against a
-- value and serializing in a way that minimizes the differences between
-- revisions of the file.
--
-- This API does make some extra assumptions about your configuration
-- type and the way you interact with it: in particular, it assumes that
-- you have lenses for all the fields you're parsing and that you have
-- some kind of sensible default value of that configuration
-- type. Instead of providing combinators which can extract and parse a
-- field of an INI file into a value, the bidirectional API allows you to
-- declaratively associate a lens into your structure with a field of the
-- INI file.
--
-- Consider the following example INI file:
--
-- > [NETWORK]
-- > host = example.com
-- > port = 7878
-- >
-- > [LOCAL]
-- > user = terry
--
-- We'd like to parse this INI file into a @Config@ type which we've
-- defined like this, using
-- <https://hackage.haskell.org/package/lens lens> or a similar library
-- to provide lenses:
--
-- > data Config = Config
-- >   { _cfHost :: String
-- >   , _cfPort :: Int
-- >   , _cfUser :: Maybe Text
-- >   } deriving (Eq, Show)
-- >
-- > ''makeLenses Config
--
-- We can now define a basic specification of the type @'IniSpec' Config
-- ()@ by using the provided operations to declare our top-level
-- sections, and then within those sections we can associate fields with
-- @Config@ lenses.
--
-- @
-- 'configSpec' :: 'IniSpec' Config ()
-- 'configSpec' = do
--   'section' \"NETWORK\" $ do
--     cfHost '.=' 'field' \"host\" 'string'
--     cfPost '.=' 'field' \"port\" 'number'
--   'sectionOpt' \"LOCAL\" $ do
--     cfUser '.=?' 'field' \"user\" 'text'
-- @
--
-- There are two operators used to associate lenses with fields:
--
-- ['.='] Associates a lens of type @Lens' s a@ with a field description
--        of type @FieldDescription a@. By default, this will raise an
--        error when parsing if the field described is missing, but we
--        can mark it as optional, as we'll see.
--
-- ['.=?'] Associates a lens of type @Lens' s (Maybe a)@ with a field
--         description of type @FieldDescription a@. During parsing, if
--         the value does not appear in an INI file, then the lens will
--         be set to 'Nothing'; similarly, during serializing, if the
--         value is 'Nothing', then the field will not be serialized in
--         the file.
--
-- Each field must include the field's name as well as a 'FieldValue',
-- which describes how to both parse and serialize a value of a given
-- type. Several built-in 'FieldValue' descriptions are provided, but you
-- can always build your own by providing parsing and serialization
-- functions for individual fields.
--
-- We can also provide extra metadata about a field, allowing it to be
-- skipped durin parsing, or to provide an explicit default value, or to
-- include an explanatory comment for that value to be used when we
-- serialize an INI file. These are conventionally applied to the field
-- using the '&' operator:
--
-- @
-- configSpec :: 'IniSpec' Config ()
-- configSpec = do
--   'section' \"NETWORK\" $ do
--     cfHost '.=' 'field' \"host\" 'string'
--                 & 'comment' [\"The desired hostname (optional)\"]
--                 & 'optional'
--     cfPost '.=' 'field' \"port\" 'number'
--                 & 'comment' [\"The port number\"]
--   'sectionOpt' \"LOCAL\" $ do
--     cfUser '.=?' 'field' \"user\" 'text'
-- @
--
-- When we want to use this specification, we need to create a value of
-- type 'Ini', which is an abstract representation of an INI
-- specification. To create an 'Ini' value, we need to use the 'ini'
-- function, which combines the spec with the default version of our
-- configuration value.
--
-- Once we have a value of type 'Ini', we can use it for three basic
-- operations:
--
-- * We can parse a textual INI file with 'parseIni', which will
--   systematically walk the spec and use the provided lens/field
--   associations to create a parsed configuration file. This will give
--   us a new value of type 'Ini' that represents the parsed
--   configuration, and we can extract the actual configuration value
--   with 'getIniValue'.
--
-- * We can update the value contained in an 'Ini' value. If the 'Ini'
--   value is the result of a previous call to 'parseIni', then this
--   update will attempt to retain as much of the incidental structure of
--   the parsed file as it can: for example, it will attempt to retain
--   comments, whitespace, and ordering. The general strategy is to make
--   the resulting INI file "diff-minimal": the diff between the older
--   INI file and the updated INI file should contain as little noise as
--   possible. Small cosmetic choices such as how to treat generated
--   comments are controlled by a configurable 'UpdatePolicy' value.
--
-- * We can serialize an 'Ini' value to a textual INI file. This will
--   produce the specified INI file (either a default fresh INI, or a
--   modified existing INI) as a textual value.
module Data.Ini.Config.Bidir
  ( -- * Parsing, Serializing, and Updating Files
    -- $using
    Ini,
    ini,
    getIniValue,
    iniValueL,
    getRawIni,

    -- ** Parsing INI files
    parseIni,

    -- ** Serializing INI files
    serializeIni,

    -- ** Updating INI Files
    updateIni,
    setIniUpdatePolicy,
    UpdatePolicy (..),
    UpdateCommentPolicy (..),
    defaultUpdatePolicy,

    -- * Bidirectional Parser Types
    -- $types
    IniSpec,
    SectionSpec,

    -- * Section-Level Parsing
    -- $sections
    section,
    allOptional,

    -- * Field-Level Parsing
    -- $fields
    FieldDescription,
    (.=),
    (.=?),
    field,
    flag,
    comment,
    placeholderValue,
    optional,

    -- * FieldValues
    -- $fieldvalues
    FieldValue (..),
    text,
    string,
    number,
    bool,
    readable,
    listWithSeparator,
    pairWithSeparator,

    -- * Miscellaneous Helpers
    -- $misc
    (&),
    Lens,
  )
where

import Control.Monad.Trans.State.Strict (State, modify, runState)
import qualified Control.Monad.Trans.State.Strict as State
import qualified Data.Foldable as F
#if __GLASGOW_HASKELL__ >= 710
import           Data.Function ((&))
#endif

import Data.Ini.Config.Raw
import Data.Monoid ((<>))
import Data.Sequence (Seq, ViewL (..), ViewR (..), (<|))
import qualified Data.Sequence as Seq
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Traversable as F
import Data.Typeable (Proxy (..), Typeable, typeRep)
import GHC.Exts (IsList (..))
import Text.Read (readMaybe)

-- * Utility functions + lens stuffs

-- | This is a
--   <https://hackage.haskell.org/package/lens lens>-compatible
--   type alias
type Lens s t a b = forall f. Functor f => (a -> f b) -> s -> f t

-- These are some inline reimplementations of "lens" operators. We
-- need the identity functor to implement 'set':
newtype I a = I {fromI :: a}

instance Functor I where fmap f (I x) = I (f x)

set :: Lens s t a b -> b -> s -> t
set lns x a = fromI (lns (const (I x)) a)

-- ... and we need the const functor to implement 'get':
newtype C a b = C {fromC :: a}

instance Functor (C a) where fmap _ (C x) = C x

get :: Lens s t a b -> s -> a
get lns a = fromC (lns C a)

lkp :: NormalizedText -> Seq (NormalizedText, a) -> Maybe a
lkp t = fmap snd . F.find (\(t', _) -> t' == t)

rmv :: NormalizedText -> Seq (Field s) -> Seq (Field s)
rmv n = Seq.filter (\f -> fieldName f /= n)

-- The & operator is really useful here, but it didn't show up in
-- earlier versions, so it gets redefined here.
#if __GLASGOW_HASKELL__ < 710
{- | '&' is a reverse application operator. This provides notational
     convenience. Its precedence is one higher than that of the
     forward application operator '$', which allows '&' to be nested
     in '$'. -}
(&) :: a -> (a -> b) -> b
a & f = f a
infixl 1 &
#endif

-- * The 'Ini' type

-- | An 'Ini' is an abstract representation of an INI file, including
-- both its textual representation and the Haskell value it
-- represents.
data Ini s = Ini
  { iniSpec :: Spec s,
    iniCurr :: s,
    iniDef :: s,
    iniLast :: Maybe RawIni,
    iniPol :: UpdatePolicy
  }

-- | Create a basic 'Ini' value from a default value and a spec.
ini :: s -> IniSpec s () -> Ini s
ini def (IniSpec spec) =
  Ini
    { iniSpec = runBidirM spec,
      iniCurr = def,
      iniDef = def,
      iniLast = Nothing,
      iniPol = defaultUpdatePolicy
    }

-- | Get the underlying Haskell value associated with the 'Ini'.
getIniValue :: Ini s -> s
getIniValue = iniCurr

mkLens :: (a -> b) -> (b -> a -> a) -> Lens a a b b
mkLens get' set' f a = (`set'` a) `fmap` f (get' a)

-- | The lens equivalent of 'getIniValue'
iniValueL :: Lens (Ini s) (Ini s) s s
iniValueL = mkLens iniCurr (\i v -> v {iniCurr = i})

-- | Get the textual representation of an 'Ini' value. If this 'Ini'
-- value is the result of 'parseIni', then it will attempt to retain
-- the textual characteristics of the parsed version as much as
-- possible (e.g. by retaining comments, ordering, and whitespace in a
-- way that will minimize the overall diff footprint.) If the 'Ini'
-- value was created directly from a value and a specification, then
-- it will pretty-print an initial version of the file with the
-- comments and placeholder text specified in the spec.
serializeIni :: Ini s -> Text
serializeIni = printRawIni . getRawIni

-- | Get the underlying 'RawIni' value for the file.
getRawIni :: Ini s -> RawIni
getRawIni Ini {iniLast = Just raw} = raw
getRawIni
  Ini
    { iniCurr = s,
      iniSpec = spec
    } =
    emitIniFile s spec

-- | Parse a textual representation of an 'Ini' file. If the file is
-- malformed or if an obligatory field is not found, this will produce
-- a human-readable error message. If an optional field is not found,
-- then it will fall back on the existing value contained in the
-- provided 'Ini' structure.
parseIni :: Text -> Ini s -> Either String (Ini s)
parseIni
  t
  i@Ini
    { iniSpec = spec,
      iniCurr = def
    } = do
    RawIni raw <- parseRawIni t
    s <- parseSections def (Seq.viewl spec) raw
    return $
      i
        { iniCurr = s,
          iniLast = Just (RawIni raw)
        }

-- | Update the internal value of an 'Ini' file. If this 'Ini' value
-- is the result of 'parseIni', then the resulting 'Ini' value will
-- attempt to retain the textual characteristics of the parsed version
-- as much as possible (e.g. by retaining comments, ordering, and
-- whitespace in a way that will minimize the overall diff footprint.)
updateIni :: s -> Ini s -> Ini s
updateIni new i =
  case doUpdateIni new i of
    Left err -> error err
    Right i' -> i'

-- | Use the provided 'UpdatePolicy' as a guide when creating future
-- updated versions of the given 'Ini' value.
setIniUpdatePolicy :: UpdatePolicy -> Ini s -> Ini s
setIniUpdatePolicy pol i = i {iniPol = pol}

-- * Type definitions

-- | A value of type 'FieldValue' packages up a parser and emitter
--   function into a single value. These are used for bidirectional
--   parsing and emitting of the value of a field.
data FieldValue a = FieldValue
  { -- | The function to use when parsing the value of a field; if
    --   the parser fails, then the string will be shown as an error
    --   message to the user.
    fvParse :: Text -> Either String a,
    -- | The function to use when serializing a value into an INI
    -- file.
    fvEmit :: a -> Text
  }

-- This is actually being used as a writer monad, but using a state
-- monad lets us avoid the space leaks. Not that those are likely to
-- be a problem in this application, but it's not like it cost us
-- none.
type BidirM s a = State (Seq s) a

runBidirM :: BidirM s a -> Seq s
runBidirM = snd . flip runState Seq.empty

type Spec s = Seq (Section s)

-- | An 'IniSpec' value represents the structure of an entire
-- INI-format file in a declarative way. The @s@ parameter represents
-- the type of a Haskell structure which is being serialized to or
-- from.
newtype IniSpec s a = IniSpec (BidirM (Section s) a)
  deriving (Functor, Applicative, Monad)

-- | A 'SectionSpec' value represents the structure of a single
-- section of an INI-format file in a declarative way. The @s@
-- parameter represents the type of a Haskell structure which is being
-- serialized to or from.
newtype SectionSpec s a = SectionSpec (BidirM (Field s) a)
  deriving (Functor, Applicative, Monad)

-- * Sections

-- | Define the specification of a top-level INI section.
section :: Text -> SectionSpec s () -> IniSpec s ()
section name (SectionSpec mote) = IniSpec $ do
  let fields = runBidirM mote
  modify (Seq.|> Section (normalize name) fields (allFieldsOptional fields))

allFieldsOptional :: Seq (Field s) -> Bool
allFieldsOptional = all isOptional
  where
    isOptional (Field _ fd) = fdSkipIfMissing fd
    isOptional (FieldMb _ _) = True

-- | Treat an entire section as containing entirely optional fields.
allOptional ::
  (SectionSpec s () -> IniSpec s ()) ->
  (SectionSpec s () -> IniSpec s ())
allOptional k spec = IniSpec $ do
  let IniSpec comp = k spec
  comp
  modify
    ( \s -> case Seq.viewr s of
        EmptyR -> s
        rs :> Section name fields _ ->
          rs Seq.|> Section name (fmap makeOptional fields) True
    )

makeOptional :: Field s -> Field s
makeOptional (Field l d) = Field l d {fdSkipIfMissing = True}
makeOptional (FieldMb l d) = FieldMb l d {fdSkipIfMissing = True}

data Section s = Section NormalizedText (Seq (Field s)) Bool

-- * Fields

-- | A "Field" is a description of
data Field s
  = forall a. Eq a => Field (Lens s s a a) (FieldDescription a)
  | forall a. Eq a => FieldMb (Lens s s (Maybe a) (Maybe a)) (FieldDescription a)

-- convenience accessors for things in a Field
fieldName :: Field s -> NormalizedText
fieldName (Field _ FieldDescription {fdName = n}) = n
fieldName (FieldMb _ FieldDescription {fdName = n}) = n

fieldComment :: Field s -> Seq Text
fieldComment (Field _ FieldDescription {fdComment = n}) = n
fieldComment (FieldMb _ FieldDescription {fdComment = n}) = n

-- | A 'FieldDescription' is a declarative representation of the
-- structure of a field. This includes the name of the field and the
-- 'FieldValue' used to parse and serialize values of that field, as
-- well as other metadata that might be needed in the course of
-- parsing or serializing a structure.
data FieldDescription t = FieldDescription
  { fdName :: NormalizedText,
    fdValue :: FieldValue t,
    fdComment :: Seq Text,
    fdDummy :: Maybe Text,
    fdSkipIfMissing :: Bool
  }

-- ** Field operators

-- |
-- Associate a field description with a field. If this field
-- is not present when parsing, it will attempt to fall back
-- on a default, and if no default value is present, it will
-- fail to parse.
--
-- When serializing an INI file, this will produce all the
-- comments associated with the field description followed
-- by the value of the field in the.
(.=) :: Eq t => Lens s s t t -> FieldDescription t -> SectionSpec s ()
l .= f = SectionSpec $ modify (Seq.|> fd)
  where
    fd = Field l f

-- |
-- Associate a field description with a field of type "Maybe a".
-- When parsing, this field will be initialized to "Nothing" if
-- it is not found, and to a "Just" value if it is. When
-- serializing an INI file, this will try to serialize a value
(.=?) :: Eq t => Lens s s (Maybe t) (Maybe t) -> FieldDescription t -> SectionSpec s ()
l .=? f = SectionSpec $ modify (Seq.|> fd)
  where
    fd = FieldMb l f

-- ** Field metadata

-- |
-- Associate a multiline comment with a "FieldDescription". When
-- serializing a field that has a comment associated, the comment will
-- appear before the field.
comment :: [Text] -> FieldDescription t -> FieldDescription t
comment cmt fd = fd {fdComment = Seq.fromList cmt}

-- | Choose a placeholder value to be displayed for optional fields.
--   This is used when serializing an optional Ini field: the
--   field will appear commented out in the output using the
--   placeholder text as a value, so a spec that includes
--
--   @
--   myLens .=? field "x" & placeholderValue "\<val\>"
--   @
--
--   will serialize into an INI file that contains the line
--
--   @
--   # x = \<val\>
--   @
--
--   A placeholder value will only appear in the serialized output if
--   the field is optional, but will be preferred over serializing the
--   default value for an optional field. This will not affect INI
--   file updates.
placeholderValue :: Text -> FieldDescription t -> FieldDescription t
placeholderValue t fd = fd {fdDummy = Just t}

-- | If the field is not found in parsing, simply skip instead of
--   raising an error or setting anything.
optional :: FieldDescription t -> FieldDescription t
optional fd = fd {fdSkipIfMissing = True}

infixr 0 .=

infixr 0 .=?

-- ** Creating fields

-- | Create a description of a field by a combination of the name of
--   the field and a "FieldValue" describing how to parse and emit
--   values associated with that field.
field :: Text -> FieldValue a -> FieldDescription a
field name value =
  FieldDescription
    { fdName = normalize (name <> " "),
      fdValue = value,
      fdComment = Seq.empty,
      fdDummy = Nothing,
      fdSkipIfMissing = False
    }

-- | Create a description of a 'Bool'-valued field.
flag :: Text -> FieldDescription Bool
flag name = field name bool

-- ** FieldValues

-- | A "FieldValue" for parsing and serializing values according to
--   the logic of the "Read" and "Show" instances for that type,
--   providing a convenient human-readable error message if the
--   parsing step fails.
readable :: forall a. (Show a, Read a, Typeable a) => FieldValue a
readable = FieldValue {fvParse = parse, fvEmit = emit}
  where
    emit = T.pack . show
    parse t = case readMaybe (T.unpack t) of
      Just v -> Right v
      Nothing ->
        Left
          ( "Unable to parse " ++ show t
              ++ " as a value of type "
              ++ show typ
          )
    typ = typeRep prx
    prx :: Proxy a
    prx = Proxy

-- | Represents a numeric field whose value is parsed according to the
-- 'Read' implementation for that type, and is serialized according to
-- the 'Show' implementation for that type.
number :: (Show a, Read a, Num a, Typeable a) => FieldValue a
number = readable

-- | Represents a field whose value is a 'Text' value
text :: FieldValue Text
text = FieldValue {fvParse = Right, fvEmit = id}

-- | Represents a field whose value is a 'String' value
string :: FieldValue String
string = FieldValue {fvParse = Right . T.unpack, fvEmit = T.pack}

-- | Represents a field whose value is a 'Bool' value. This parser is
-- case-insensitive, and matches the words @true@, @false@, @yes@, and
-- @no@, as well as single-letter abbreviations for all of the
-- above. This will serialize as @true@ for 'True' and @false@ for
-- 'False'.
bool :: FieldValue Bool
bool = FieldValue {fvParse = parse, fvEmit = emit}
  where
    parse s = case T.toLower s of
      "true" -> Right True
      "yes" -> Right True
      "t" -> Right True
      "y" -> Right True
      "false" -> Right False
      "no" -> Right False
      "f" -> Right False
      "n" -> Right False
      _ -> Left ("Unable to parse " ++ show s ++ " as a boolean")
    emit True = "true"
    emit False = "false"

-- | Represents a field whose value is a sequence of other values
-- which are delimited by a given string, and whose individual values
-- are described by another 'FieldValue' value. This uses GHC's
-- `IsList` typeclass to convert back and forth between sequence
-- types.
listWithSeparator :: IsList l => Text -> FieldValue (Item l) -> FieldValue l
listWithSeparator sep fv =
  FieldValue
    { fvParse = fmap fromList . mapM (fvParse fv . T.strip) . T.splitOn sep,
      fvEmit = T.intercalate sep . map (fvEmit fv) . toList
    }

-- | Represents a field whose value is a pair of two other values
-- separated by a given string, whose individual values are described
-- by two different 'FieldValue' values.
pairWithSeparator :: FieldValue l -> Text -> FieldValue r -> FieldValue (l, r)
pairWithSeparator left sep right =
  FieldValue
    { fvParse = \t ->
        let (leftChunk, rightChunk) = T.breakOn sep t
         in do
              x <- fvParse left leftChunk
              y <- fvParse right rightChunk
              return (x, y),
      fvEmit = \(x, y) -> fvEmit left x <> sep <> fvEmit right y
    }

-- * Parsing INI files

-- Are you reading this source code? It's not even that gross
-- yet. Just you wait. This is just the regular part. 'runSpec' is
-- easy: we walk the spec, and for each section, find the
-- corresponding section in the INI file and call runFields.
parseSections ::
  s ->
  Seq.ViewL (Section s) ->
  Seq (NormalizedText, IniSection) ->
  Either String s
parseSections s Seq.EmptyL _ = Right s
parseSections s (Section name fs opt Seq.:< rest) i
  | Just v <- lkp name i = do
    s' <- parseFields s (Seq.viewl fs) v
    parseSections s' (Seq.viewl rest) i
  | opt = parseSections s (Seq.viewl rest) i
  | otherwise =
    Left
      ( "Unable to find section "
          ++ show (normalizedText name)
      )

-- Now that we've got 'set', we can walk the field descriptions and
-- find them. There's some fiddly logic, but the high-level idea is
-- that we try to look up a field, and if it exists, parse it using
-- the provided parser and use the provided lens to add it to the
-- value. We have to decide what to do if it's not there, which
-- depends on lens metadata and whether it's an optional field or not.
parseFields :: s -> Seq.ViewL (Field s) -> IniSection -> Either String s
parseFields s Seq.EmptyL _ = Right s
parseFields s (Field l descr Seq.:< fs) sect
  | Just v <- lkp (fdName descr) (isVals sect) = do
    value <- fvParse (fdValue descr) (T.strip (vValue v))
    parseFields (set l value s) (Seq.viewl fs) sect
  | fdSkipIfMissing descr =
    parseFields s (Seq.viewl fs) sect
  | otherwise =
    Left
      ( "Unable to find field "
          ++ show (normalizedText (fdName descr))
      )
parseFields s (FieldMb l descr Seq.:< fs) sect
  | Just v <- lkp (fdName descr) (isVals sect) = do
    value <- fvParse (fdValue descr) (T.strip (vValue v))
    parseFields (set l (Just value) s) (Seq.viewl fs) sect
  | otherwise =
    parseFields (set l Nothing s) (Seq.viewl fs) sect

-- | Serialize a value as an INI file according to a provided
-- 'IniSpec'.
emitIniFile :: s -> Spec s -> RawIni
emitIniFile s spec =
  RawIni $
    fmap
      ( \(Section name fs _) ->
          (name, toSection s (actualText name) fs)
      )
      spec

mkComments :: Seq Text -> Seq BlankLine
mkComments = fmap (\ln -> CommentLine '#' (" " <> ln))

toSection :: s -> Text -> Seq (Field s) -> IniSection
toSection s name fs =
  IniSection
    { isName = name,
      isVals = fmap toVal fs,
      isStartLine = 0,
      isEndLine = 0,
      isComments = Seq.empty
    }
  where
    mkIniValue val descr opt =
      ( fdName descr,
        IniValue
          { vLineNo = 0,
            vName = actualText (fdName descr),
            vValue = " " <> val,
            vComments = mkComments (fdComment descr),
            vCommentedOut = opt,
            vDelimiter = '='
          }
      )
    toVal (Field l descr)
      | Just dummy <- fdDummy descr =
        mkIniValue dummy descr False
      | otherwise =
        mkIniValue (fvEmit (fdValue descr) (get l s)) descr False
    toVal (FieldMb l descr)
      | Just dummy <- fdDummy descr =
        mkIniValue dummy descr True
      | Just v <- get l s =
        mkIniValue (fvEmit (fdValue descr) v) descr True
      | otherwise =
        mkIniValue "" descr True

-- | An 'UpdatePolicy' guides certain choices made when an 'Ini' file
-- is updated: for example, how to add comments to the generated
-- fields, or how to treat fields which are optional.
data UpdatePolicy = UpdatePolicy
  { -- | If 'True', then optional fields not included in the INI file
    -- will be included in the updated INI file. Defaults to 'False'.
    updateAddOptionalFields :: Bool,
    -- | If 'True', then fields in the INI file that have no
    -- corresponding description in the 'IniSpec' will be ignored; if
    -- 'False', then those fields will return an error value. Defaults
    -- to 'True'.
    updateIgnoreExtraneousFields :: Bool,
    -- | The policy for what to do to comments associated with
    -- modified fields during an update. Defaults to
    -- 'CommentPolicyNone'.
    updateGeneratedCommentPolicy :: UpdateCommentPolicy
  }
  deriving (Eq, Show)

-- | A set of sensible 'UpdatePolicy' defaults which keep the diffs
-- between file versions minimal.
defaultUpdatePolicy :: UpdatePolicy
defaultUpdatePolicy =
  UpdatePolicy
    { updateAddOptionalFields = False,
      updateIgnoreExtraneousFields = True,
      updateGeneratedCommentPolicy = CommentPolicyNone
    }

-- | An 'UpdateCommentPolicy' describes what comments should accompany
-- a field added to or modified in an existing INI file when using
-- 'updateIni'.
data UpdateCommentPolicy
  = -- | Do not add comments to new fields
    CommentPolicyNone
  | -- | Add the same comment which appears in the 'IniSpec' value for
    -- the field we're adding or modifying.
    CommentPolicyAddFieldComment
  | -- | Add a common comment to all new fields added or modified
    -- by an 'updateIni' call.
    CommentPolicyAddDefaultComment (Seq Text)
  deriving (Eq, Show)

getComments :: FieldDescription s -> UpdateCommentPolicy -> Seq BlankLine
getComments _ CommentPolicyNone = Seq.empty
getComments f CommentPolicyAddFieldComment =
  mkComments (fdComment f)
getComments _ (CommentPolicyAddDefaultComment cs) =
  mkComments cs

-- | Given a value, an 'IniSpec', and a 'Text' form of an INI file,
-- parse 'Text' as INI and then selectively modify the file whenever
-- the provided value differs from the file. This is designed to help
-- applications update a user's configuration automatically while
-- retaining the structure and comments of a user's application,
-- ideally in a way which produces as few changes as possible to the
-- resulting file (so that, for example, the diff between the two
-- should be as small as possible.)
--
--  A field is considered to have "changed" if the parsed
--  representation of the field as extracted from the textual INI file
--  is not equal to the corresponding value in the provided
--  structure. Changed fields will retain their place in the overall
--  file, while newly added fields (for example, fields which have
--  been changed from a default value) will be added to the end of the
--  section in which they appear.
-- doUpdateIni :: s -> s -> Spec s -> RawIni -> UpdatePolicy -> Either String (Ini s)
doUpdateIni :: s -> Ini s -> Either String (Ini s)
doUpdateIni
  s
  i@Ini
    { iniSpec = spec,
      iniDef = def,
      iniPol = pol
    } = do
    -- spec (RawIni ini) pol = do
    let RawIni ini' = getRawIni i
    res <- updateSections s def ini' spec pol
    return $
      i
        { iniCurr = s,
          iniLast = Just (RawIni res)
        }

updateSections ::
  s ->
  s ->
  Seq (NormalizedText, IniSection) ->
  Seq (Section s) ->
  UpdatePolicy ->
  Either String (Seq (NormalizedText, IniSection))
updateSections s def sections fields pol = do
  -- First, we process all the sections that actually appear in the
  -- INI file in order
  existingSections <- F.for sections $ \(name, sec) -> do
    let err = Left ("Unexpected top-level section: " ++ show name)
    Section _ spec _ <-
      maybe
        err
        Right
        (F.find (\(Section n _ _) -> n == name) fields)
    newVals <- updateFields s (isVals sec) spec pol
    return (name, sec {isVals = newVals})
  -- And then
  let existingSectionNames = fmap fst existingSections
  newSections <- F.for fields $
    \(Section nm spec _) ->
      if nm `elem` existingSectionNames
        then return mempty
        else
          let rs = emitNewFields s def spec pol
           in if Seq.null rs
                then return mempty
                else
                  return $
                    Seq.singleton
                      ( nm,
                        IniSection (actualText nm) rs 0 0 mempty
                      )
  return (existingSections <> F.asum newSections)

-- We won't emit a section if everything in the section is also
-- missing
emitNewFields ::
  s ->
  s ->
  Seq (Field s) ->
  UpdatePolicy ->
  Seq (NormalizedText, IniValue)
emitNewFields s def fields pol = go (Seq.viewl fields)
  where
    go EmptyL = Seq.empty
    go (Field l d :< fs)
      -- If a field is not present but is also the same as the default,
      -- then we can safely omit it
      | get l s == get l def && not (updateAddOptionalFields pol) =
        go (Seq.viewl fs)
      -- otherwise, we should add it to the result
      | otherwise =
        let cs = getComments d (updateGeneratedCommentPolicy pol)
            new =
              ( fdName d,
                IniValue
                  { vLineNo = 0,
                    vName = actualText (fdName d),
                    vValue = " " <> fvEmit (fdValue d) (get l s),
                    vComments = cs,
                    vCommentedOut = False,
                    vDelimiter = '='
                  }
              )
         in new <| go (Seq.viewl fs)
    go (FieldMb l d :< fs) =
      case get l s of
        Nothing -> go (Seq.viewl fs)
        Just v ->
          let cs = getComments d (updateGeneratedCommentPolicy pol)
              new =
                ( fdName d,
                  IniValue
                    { vLineNo = 0,
                      vName = actualText (fdName d),
                      vValue = fvEmit (fdValue d) v,
                      vComments = cs,
                      vCommentedOut = False,
                      vDelimiter = '='
                    }
                )
           in new <| go (Seq.viewl fs)

updateFields ::
  s ->
  Seq (NormalizedText, IniValue) ->
  Seq (Field s) ->
  UpdatePolicy ->
  Either String (Seq (NormalizedText, IniValue))
updateFields s values fields pol = go (Seq.viewl values) fields
  where
    go ((t, val) :< vs) fs =
      -- For each field, we need to fetch the description of the
      -- field in the spec
      case F.find (\f -> fieldName f == t) fs of
        Just f@(Field l descr) ->
          -- if it does exist, then we need to find out whether
          -- the field has changed at all. We can do this with the
          -- provided lens, and check it against the INI file
          -- we've got. There's a minor complication: there's
          -- nothing that forces the user to provide the same INI
          -- file we originally parsed! One side-effect means that
          -- the parsed INI file might not actually have a valid
          -- field according to the field parser the user
          -- provides. In that case, we'll assume the field is
          -- outdated, and update it with the value in the
          -- provided structure.
          if Right (get l s) == fvParse (fdValue descr) (T.strip (vValue val))
            then -- if the value in the INI file parses the same as
            -- the one in the structure we were passed, then it
            -- doesn't need any updating, and we keep going,
            -- removing the field from our list
              ((t, val) <|) `fmap` go (Seq.viewl vs) (rmv t fs)
            else -- otherwise, we've got a new updated value! Let's
            -- synthesize a new element, using our comment policy
            -- to comment it accordingly. (This pattern is
            -- partial, but we should never have a situation
            -- where it returns Nothing, because we already know
            -- that we've matched a Field!)

              let Just nv = mkValue t f (vDelimiter val)
               in ((t, nv) <|) `fmap` go (Seq.viewl vs) (rmv t fs)
        -- And we have to replicate the logic for the FieldMb
        -- case, because (as an existential) it doesn't really
        -- permit us usable abstractions here. See the previous
        -- comments for descriptions of the cases.
        Just f@(FieldMb l descr) ->
          let parsed = fvParse (fdValue descr) (T.strip (vValue val))
           in if Right (get l s) == fmap Just parsed
                then ((t, val) <|) `fmap` go (Seq.viewl vs) (rmv t fs)
                else -- this is in the only case where the FieldMb case
                -- differs: we might NOT have a value in the
                -- structure. In that case, we remove the value
                -- from the file, as well!
                case mkValue t f (vDelimiter val) of
                  Just nv -> ((t, nv) <|) `fmap` go (Seq.viewl vs) (rmv t fs)
                  Nothing -> go (Seq.viewl vs) (rmv t fs)
        -- Finally, if we can't find any description of the field,
        -- then we might skip it or throw an error, depending on
        -- the policy the user wants.
        Nothing
          | updateIgnoreExtraneousFields pol ->
            ((t, val) <|) `fmap` go (Seq.viewl vs) fs
          | otherwise -> Left ("Unexpected field: " ++ show t)
    -- Once we've gone through all the fields in the file, we need
    -- to see if there's anything left over that should be in the
    -- file. We might want to include dummy values for things that
    -- were left out, but if we have any non-optional fields left
    -- over, then we definitely need to include them.
    go EmptyL fs = return (finish (Seq.viewl fs))
    finish (f@Field {} :< fs)
      | updateAddOptionalFields pol,
        Just val <- mkValue (fieldName f) f '=' =
        (fieldName f, val) <| finish (Seq.viewl fs)
      | otherwise = finish (Seq.viewl fs)
    finish (f@(FieldMb _ descr) :< fs)
      | not (fdSkipIfMissing descr),
        Just val <- mkValue (fieldName f) f '=' =
        (fieldName f, val) <| finish (Seq.viewl fs)
      | updateAddOptionalFields pol,
        Just val <- mkValue (fieldName f) f '=' =
        (fieldName f, val) <| finish (Seq.viewl fs)
      | otherwise = finish (Seq.viewl fs)
    -- If there's nothing left, then we can return a final value!
    finish EmptyL = Seq.empty
    mkValue t fld delim =
      let comments = case updateGeneratedCommentPolicy pol of
            CommentPolicyNone -> Seq.empty
            CommentPolicyAddFieldComment ->
              mkComments (fieldComment fld)
            CommentPolicyAddDefaultComment cs ->
              mkComments cs
          val =
            IniValue
              { vLineNo = 0,
                vName = actualText t,
                vValue = "",
                vComments = comments,
                vCommentedOut = False,
                vDelimiter = delim
              }
       in case fld of
            Field l descr ->
              Just (val {vValue = " " <> fvEmit (fdValue descr) (get l s)})
            FieldMb l descr ->
              case get l s of
                Just v -> Just (val {vValue = " " <> fvEmit (fdValue descr) v})
                Nothing -> Nothing

-- $using
-- Functions for parsing, serializing, and updating INI files.

-- $types
-- Types which represent declarative specifications for INI
-- file structure.

-- $sections
-- Declaring sections of an INI file specification

-- $fields
-- Declaring individual fields of an INI file specification.

-- $fieldvalues
-- Values of type 'FieldValue' represent both a parser and a
-- serializer for a value of a given type. It's possible to manually
-- create 'FieldValue' descriptions, but for simple configurations,
-- but for the sake of convenience, several commonly-needed
-- varieties of 'FieldValue' are defined here.

-- $misc
-- These values and types are exported for compatibility.
