# Mage.py -- Pamhyr
# Copyright (C) 2023-2026  INRAE
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

# -*- coding: utf-8 -*-

import os
import logging
import numpy as np
import time
from functools import reduce

from itertools import chain
from tools import timer, trace, logger_exception

from Solver.CommandLine import CommandLineSolver
from Checker.Mage import (
    MageNetworkGraphChecker, MageGeometryGuideLineChecker,
)

from Model.Results.Results import Results
from Model.Results.River.River import River, Reach, Profile

logger = logging.getLogger()


def mage_file_open(filepath, mode):
    f = open(filepath, mode)

    if "w" in mode:
        # Write header
        comment = "*"
        if ".ST" in filepath:
            comment = "#"

        f.write(
            f"{comment} " +
            "This file is generated by PAMHYR, please don't modify\n"
        )

    return f


class Mage(CommandLineSolver):
    _type = "mage"

    def __init__(self, name):
        super(Mage, self).__init__(name)

        self._type = "mage"

        self._cmd_input = ""
        self._cmd_solver = "@path @input -o @output"
        self._cmd_output = ""

    @classmethod
    def default_parameters(cls):
        lst = super(Mage, cls).default_parameters()

        lst += [
            ("mage_min_timestep", "1.0"),
            ("mage_timestep_tra", "3600"),
            ("mage_timestep_bin", "3600"),
            # ("mage_timestep_melissa", "0"),
            ("mage_implicitation", "0.70"),
            ("mage_continuity_discretization", "S"),
            ("mage_qsj_discretization", "B"),
            ("mage_stop_criterion_iterations", "R"),
            ("mage_iteration_type", "0"),
            ("mage_smooth_coef", "0"),
            ("mage_cfl_max", "-1."),
            ("mage_min_height", "0.1"),
            ("mage_max_niter", "99"),
            ("mage_timestep_reduction_factor", "2"),
            ("mage_precision_reduction_factor_Z", "1"),
            ("mage_precision_reduction_factor_Q", "1"),
            ("mage_niter_max_precision", "10"),
            ("mage_niter_before_switch", "99"),
            ("mage_max_froude", "1.5"),
            ("mage_diffluence_node_height_balance", "-1"),
            ("mage_compute_reach_volume_balance", "y"),
            ("mage_max_reach_volume_balance", "0.001"),
            ("mage_min_reach_volume_to_check", "1000.0"),
            ("mage_init_internal", "n"),
        ]

        return lst

    @classmethod
    def checkers(cls):
        lst = [
            MageNetworkGraphChecker(connectivity=True, version=cls._type),
            MageNetworkGraphChecker(connectivity=False, version=cls._type),
            MageGeometryGuideLineChecker(version=cls._type),
        ]

        return lst

    ##########
    # Export #
    ##########

    _alph = list(
        map(
            chr,
            chain(
                range(48, 58),  # 0..9
                range(65, 91),  # A..Z
                range(97, 123)  # a..z
            )
        )
    )

    _l_alph = len(_alph)

    _nodes_cnt = 0
    _nodes_names = {}
    _nodes_views = set()

    def get_reach_name(self, reach):
        index = self._study.river.get_edge_id(reach) + 1
        return f"Reach_{index:03d}"

    def get_node_name(self, node):
        """Generate a 3 char name for node

        Args:
            node: The node

        Returns:
            A 3 char name string
        """
        n = node.pamhyr_id

        if n in self._nodes_names:
            return self._nodes_names[n]

        name = ""

        checked_new = False
        while not checked_new:
            self._nodes_cnt += 1
            nc = self._nodes_cnt

            name = "".join(
                map(
                    lambda i: self._alph[i % self._l_alph],
                    [
                        int(nc / (self._l_alph * self._l_alph)),
                        int(nc / self._l_alph),
                        nc
                    ]
                )
            )

            checked_new = name not in self._nodes_views

        self._nodes_views.add(name)
        self._nodes_names[n] = name

        return name

    def cmd_args(self, study):
        lst = super(Mage, self).cmd_args(study)

        lst.append("-r")

        return lst

    def input_param(self):
        name = self._study.name.replace(" ", "_")
        return f"{name}.REP"

    def output_param(self):
        name = self._study.name.replace(" ", "_")
        return f"{name}.BIN"

    def log_file(self):
        name = self._study.name.replace(" ", "_")
        return f"{name}.TRA"

    def _export_REP_additional_lines(self, study, rep_file):
        lines = filter(
            lambda line: line.is_enabled(),
            study.river.rep_lines.lines
        )

        for line in lines:
            rep_file.write(line.line+"\n")

    def _export_ST(self, study, repertory, qlog, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export ST file")

        os.makedirs(os.path.join(repertory, "net"), exist_ok=True)

        # Write header
        edges = study.river.enable_edges()
        for edge in edges:
            name = self.get_reach_name(edge)

            with mage_file_open(
                    os.path.join(repertory, "net", f"{name}.ST"),
                    "w+"
            ) as f:
                files.append(str(os.path.join("net", f"{name}.ST")))

                cnt_num = 1
                for profile in edge.reach.profiles:
                    self._export_ST_profile_header(
                        f, files, profile, cnt_num
                    )
                    cnt_num += 1

                    # Points
                    for point in profile.points:
                        self._export_ST_point_line(
                            f, files, point
                        )

                    # Profile last line
                    f.write(f"     999.9990     999.9990     999.9990\n")

        return files

    def _export_ST_profile_header(self, wfile, files,
                                  profile, cnt):
        num = f"{cnt:>6}"
        c1 = f"{profile.code1:>6}"
        c2 = f"{profile.code2:>6}"
        t = f"{len(profile.points):>6}"
        rk = f"{profile.rk:>12f}"[0:12]
        pname = profile.name
        if profile.name == "":
            # Generate name from profile id prefixed with
            # 'p' (and replace space char with '0' char)
            pname = f"p{profile.id:>3}".replace(" ", "0")
            name = f"{pname:<19}"

        # Generate sediment additional data if available
        sediment = ""
        if profile.sl is not None and not profile.sl.is_deleted():
            if not any(filter(lambda f: ".GRA" in f, files)):
                files.append(self._gra_file)

            # Number of layers
            nl = len(profile.sl)
            sediment = f" {nl:>3}"

            # Layers data
            for layer in profile.sl.layers:
                sediment += (
                    f" {layer.height:>10} {layer.d50:>10} " +
                    f"{layer.sigma:>10} " +
                    f"{layer.critical_constraint:>10}"
                )

        # Profile header line
        wfile.write(f"{num}{c1}{c2}{t} {rk} {pname} {sediment}\n")

    def _export_ST_point_line(self, wfile, files, point):
        x = f"{point.x:<12.4f}"[0:12]
        y = f"{point.y:<12.4f}"[0:12]
        z = f"{point.z:<12.4f}"[0:12]
        n = f"{point.name:<3}"

        # Generate sediment additional data if available
        sediment = ""
        prev = point.z

        if point.sl is not None and not point.sl.is_deleted():
            # Number of layers
            nl = len(point.sl)
            sediment = f"{nl:>3}"

            # Layers data
            for layer in point.sl.layers:
                prev = round(prev - layer.height, 5)
                sediment += (
                    f" {prev:>10} {layer.d50:>10} " +
                    f"{layer.sigma:>10} " +
                    f"{layer.critical_constraint:>10}"
                )

        # Point line
        wfile.write(f"{x} {y} {z} {n} {sediment}\n")

    def _export_BC(self, t, bounds, repertory, qlog, name="0"):
        files = []

        if len(bounds) == 0:
            return files

        if qlog is not None:
            qlog.put(f"Export {t} file")

        with mage_file_open(os.path.join(repertory, f"{name}.{t}"), "w+") as f:
            files.append(f"{name}.{t}")

            for bound in bounds:
                if bound.node is None:
                    continue

                if bound.is_deleted():
                    continue

                name = self.get_node_name(bound.node)
                f.write(f"* {bound.node.name} ({name}) {bound.bctype}\n")
                f.write(f"${name}\n")
                header = bound.header
                f.write(f"*{header[0]:>9}|{header[1]:>10}\n")

                for d in bound.data:
                    v0 = d[0]
                    v1 = d[1]

                    if t in ["HYD", "LIM"]:
                        v0 /= 60  # Convert first column to minute

                    f.write(f"{v0:9} {v1:9} \n")

        return files

    def _export_bound_cond(self, study, repertory, qlog, name="0"):
        files = []
        lst = study.river.boundary_condition

        AVA = []
        HYD = []
        LIM = []
        QSO = []

        for tab in ["liquid", "solid", "suspenssion"]:
            for bound in lst.get_tab(tab):
                if bound.node is None:
                    continue

                if not study.river.is_enable_node(bound.node):
                    continue

                if bound.bctype == "ZD":
                    AVA.append(bound)
                elif bound.bctype == "TD" or bound.bctype == "PC":
                    HYD.append(bound)
                elif bound.bctype == "TZ":
                    LIM.append(bound)
                elif bound.bctype == "SL":
                    QSO.append(bound)

        files = files + self._export_BC("AVA", AVA, repertory, qlog, name=name)
        files = files + self._export_BC("HYD", HYD, repertory, qlog, name=name)
        files = files + self._export_BC("LIM", LIM, repertory, qlog, name=name)
        files = files + self._export_QSO(QSO, repertory, qlog, name=name)

        return files

    def _export_lateral_contrib(self, study, repertory, qlog, name="0"):
        files = []
        lst = study.river.lateral_contribution

        with mage_file_open(
                os.path.join(repertory, f"{name}.LAT"),
                "w+"
        ) as f:
            if qlog is not None:
                qlog.put(f"Export LAT file")
            files.append(f"{name}.LAT")

            for tab in ["liquid", "solid", "suspenssion"]:
                for lateral in lst.get_tab(tab):
                    self._export_LC(study, lateral, f,  qlog)

        return files

    def _export_LC(self, study, lateral, f, qlog, name="0"):
        if lateral.reach is None:
            return

        if lateral.is_deleted():
            return

        edges = study.river.enable_edges()
        if lateral.reach not in edges:
            return

        eid, _ = next(
            filter(
                lambda e: e[1] == lateral.reach,
                enumerate(edges)
            )
        )

        name = f"{eid+1:>3}"
        # name = f"Reach_{lateral.reach.id + 1:>3}".replace(" ", "0")
        f.write(f"* {lateral.reach.name} ({name}) {lateral.lctype}\n")
        f.write(
            f"${name}     " +
            f"{lateral.begin_rk:>10.4f} {lateral.end_rk:>10.4f}\n"
        )
        header = lateral.header
        f.write(f"*{header[0]:>9}|{header[1]:>10}\n")

        for d in lateral.data:
            if d.is_deleted():
                continue

            if lateral.lctype in ["EV"]:
                f.write(f"{d[0]:10.3f}{-d[1]:10.3f}\n")
            else:
                f.write(f"{d[0]:10.3f}{d[1]:10.3f}\n")

    def _export_RUG(self, study, repertory, qlog, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export RUG file")

        # Write header
        with mage_file_open(os.path.join(repertory, f"{name}.RUG"), "w+") as f:
            files.append(f"{name}.RUG")

            edges = study.river.enable_edges()

            id = 1
            for edge in edges:
                frictions = edge.frictions

                for friction in frictions.frictions:
                    if friction.begin_strickler is None:
                        continue

                    if friction.is_deleted():
                        continue

                    num = f"{id:>3}"
                    brk = f"{friction.begin_rk:>10.3f}"
                    erk = f"{friction.end_rk:>10.3f}"

                    # if friction.begin_rk != friction.end_rk:
                    #     print("TODO")

                    strickler = friction.begin_strickler
                    coef_1 = f"{strickler.minor:>10.3f}"
                    coef_2 = f"{strickler.medium:>10.3f}"

                    f.write(f"K{num}      {brk}{erk}{coef_1}{coef_2}\n")

                id += 1

        return files

    def _export_INI(self, study, repertory, qlog, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export INI file")

        # Write header
        with mage_file_open(os.path.join(repertory, f"{name}.INI"), "w+") as f:
            has_ini = False
            id = 1
            reachs = study.river.enable_edges()

            # TODO put real date...
            f.write(f"$ date en minutes :       0.00\n")
            f.write(f"* IB IS    discharge  elevation         rk\n")

            id = 1
            for reach in reachs:
                cond = study.river.initial_conditions.get(reach)
                if cond.is_deleted():
                    continue

                data = cond.data
                if len(data) == 0:
                    continue

                has_ini = True

                id_sec = 1
                for d in data:
                    if d.is_deleted():
                        continue

                    IR = f"{id}"
                    IS = f"{id_sec}"
                    discharge = f"{d['discharge']:>10.5f}"
                    elevation = f"{d['elevation']:>11.6f}"
                    section = d['rk']
                    if section is None:
                        continue
                    rk = f"{section.rk:>9.2f}"

                    f.write(f"{IR} {IS} {discharge} {elevation} {rk}\n")
                    id_sec += 1

                id += 1

        if has_ini:
            files.append(f"{name}.INI")

        return files

    def _export_CAS(self, study, repertory, qlog, name="0"):
        files = []

        reservoirs = study.river.reservoir.lst
        if len(reservoirs) == 0:
            return files

        if qlog is not None:
            qlog.put("Export CAS file")

        with mage_file_open(os.path.join(repertory, f"{name}.CAS"), "w+") as f:
            files.append(f"{name}.CAS")

            for reservoir in reservoirs:
                if reservoir.node is None or reservoir.is_deleted():
                    continue

                reservoir.sort()
                node = reservoir.node
                name = self.get_node_name(node)
                f.write(f"* {node.name} ({name}) Reservoir\n")
                f.write(f"${name}\n")
                f.write(f"*{'Elev(m)':>9}|{'Area(ha)':>10}\n")

                for d in reservoir.data:
                    v0 = d[0]
                    v1 = d[1]

                    f.write(f"{v0:>10.3f}{v1:>10.3f}\n")

        return files

    def skip_hs(self, hs):
        return (
            hs.input_reach is None
            or not hs.input_reach.is_enable()
            or not hs.enabled
            or hs.input_section is None
            or hs.is_deleted()
        )

    def _export_SIN(self, study, repertory, qlog, name="0"):
        files = []

        sin_dict = {
            "ND": "*",
            "S1": "D", "S2": "T", "S3": "T",
            "OR": "O", "OC": "B", "OV": "F",
            "CV": "O",          # CheckValve
            "V1": "V", "V2": "W",
            "BO": "A",
            "UD": "X",
            "PO": "P",
        }

        hydraulic_structures = study.river.hydraulic_structures.lst
        if len(hydraulic_structures) == 0:
            return files

        if qlog is not None:
            qlog.put("Export SIN file")

        with mage_file_open(os.path.join(repertory, f"{name}.SIN"), "w+") as f:
            files.append(f"{name}.SIN")

            for hs in hydraulic_structures:
                if self.skip_hs(hs):
                    continue

                f.write(
                    '* ouvrage au pk ' +
                    f"{float(hs.input_section.rk):>12.1f}" + ' ' +
                    hs.name + '\n'
                )

                self._export_SIN_bhs(study, sin_dict, hs, f)

        return files

    def _export_SIN_bhs(self, study, sin_dict, hs, f):
        for bhs in hs.basic_structures:
            if not bhs.enabled:
                continue

            if bhs.is_deleted():
                continue

            reach_id = study.river.get_edge_id(hs.input_reach) + 1
            param_str = ' '.join(
                [
                    f'{p:>10.3f}'
                    for p in self._export_SIN_parameters(bhs)
                ]
            )

            name = bhs.name
            if name == "":
                name = f"HS_{bhs.id:>3}".replace(" ", "0")
            else:
                name = name.replace(" ", "_")

            f.write(
                f"{sin_dict[bhs._type]} " +
                f"{reach_id} {float(hs.input_section.rk):>12.3f} " +
                f"{param_str} {name}\n"
            )

    def _export_SIN_parameters(self, bhs):
        res = [9999.999] * 5

        if len(bhs) == 5:
            res = self._export_SIN_parameters_5(bhs)
        elif len(bhs) == 4:
            res = self._export_SIN_parameters_4(bhs)
        elif len(bhs) == 3:
            res = self._export_SIN_parameters_3(bhs)

        return res

    def _export_SIN_parameters_5(self, bhs):
        # S2, OR, V1, V2, UD, CV
        return [
            bhs._data[0].value,
            bhs._data[1].value,
            bhs._data[2].value,
            bhs._data[3].value,
            bhs._data[4].value,
        ]

    def _export_SIN_parameters_4(self, bhs):
        # S3, OC
        res = [
            bhs._data[0].value,
            bhs._data[1].value,
            bhs._data[2].value,
            bhs._data[3].value,
            0.0,
        ]

        if bhs._type == "T":    # S3
            res = [0.0] + res[:-1]

        return res

    def _export_SIN_parameters_3(self, bhs):
        # S1, BO
        if bhs._type == "S1":
            res = [
                bhs._data[0].value,
                bhs._data[1].value,
                0.0,
                bhs._data[2].value,
                9999.99,
            ]
        else:
            res = [
                bhs._data[0].value,
                bhs._data[1].value,
                bhs._data[2].value,
                0.0,
                0.0,
            ]

        return res

    def _export_VAR(self, study, repertory, qlog, name="0"):
        files = []

        hydraulic_structures = study.river.hydraulic_structures.lst
        if len(hydraulic_structures) == 0:
            return files

        if qlog is not None:
            qlog.put("Export VAR file")

        nb_cv = 0
        for hs in hydraulic_structures:
            if not hs.enabled or hs.is_deleted():
                continue

            if hs.input_reach is None:
                continue

            if not hs.input_reach.is_enable() or hs.input_reach.is_deleted():
                continue

            for bhs in hs.basic_structures:
                if bhs.enabled:
                    logger.info(bhs._type)
                    if bhs._type == "CV":
                        nb_cv += 1

        if nb_cv != 0:
            with mage_file_open(os.path.join(
                                repertory, f"{name}.VAR"), "w+") as f:
                files.append(f"{name}.VAR")

                for hs in hydraulic_structures:
                    if hs.input_reach is None:
                        continue

                    if not hs.input_reach.is_enable():
                        continue

                    if not hs.enabled:
                        continue

                    for bhs in hs.basic_structures:
                        logger.info(bhs._type)
                        if bhs._type != "CV":
                            continue

                        name = bhs.name
                        if name == "":
                            name = f"HS_{bhs.id:>3}".replace(" ", "0")

                        f.write(
                            f"${name} clapet"
                        )
        return files

    def _export_DEV(self, study, repertory, qlog, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export DEV file")

        with mage_file_open(
                os.path.join(
                    repertory, f"{name}.DEV"
                ), "w+"
        ) as f:
            reachs = study.river.enable_edges()

            id = 1
            for reach in reachs:
                f.write(f"YD{id:3}\n")
                f.write(f"YG{id:3}\n")
                id += 1
            files.append(f"{name}.DEV")

        return files

    def _export_REP(self, study, repertory, files, qlog, name="0"):
        if qlog is not None:
            qlog.put("Export REP file")

        # Write header
        with mage_file_open(
                os.path.join(
                    repertory, f"{name}.REP"
                ), "w+"
        ) as f:
            f.write("confirmation=non\n")

            for file in files:
                EXT = file.split('.')[1]

                if EXT not in ["ST", "GRA"]:
                    f.write(f"{EXT} {file}\n")

            f.write("* OUTPUT\n")
            f.write(f"TRA {name}.TRA\n")
            f.write(f"BIN {name}.BIN\n")
            f.write(f"ERR {name}.ERR\n")

            for file in files:
                EXT = file.split('.')[1]

                if EXT in ["GRA"]:
                    f.write(f"{EXT} {file}\n")

            self._export_REP_additional_lines(study, f)

    @timer
    def export(self, study, repertory, qlog=None):
        self._study = study
        name = study.name.replace(" ", "_")

        # Define GRA file name
        self._gra_file = f"{name}.GRA"
        self._bin_file = f"{name}.BIN"

        self._export_ST(study, repertory, qlog, name=name)
        self.export_additional_files(study, repertory, qlog, name=name)
        self.export_study_description(study, repertory, qlog, name=name)

        return True

    ###########
    # RESULTS #
    ###########

    def read_bin(self, study, fname, results, qlog=None, name="0"):
        return

    @timer
    def results(self, study, repertory, qlog=None, name="0"):
        results = Results(
            study=study,
            solver=self,
            repertory=repertory,
            name=name,
        )

        fname = os.path.join(repertory, f"{name}.BIN".replace(" ", "_"))
        if not os.path.isfile(fname):
            logger.info(f"Result file {name}.BIN does not exist")
            return None
        try:
            self.read_bin(study, fname, results, qlog, name=name)
        except Exception as e:
            logger.error(f"Failed to read results")
            logger_exception(e)
            return None

        return results

##########
# MAGE 7 #
##########


class Mage7(Mage):
    _type = "mage7"

    def __init__(self, name):
        super(Mage7, self).__init__(name)

        self._type = "mage7"

    @classmethod
    def default_parameters(cls):
        lst = super(Mage7, cls).default_parameters()

        return lst

##########
# MAGE 8 #
##########


class Mage8(Mage):
    _type = "mage8"

    def __init__(self, name):
        super(Mage8, self).__init__(name)

        self._type = "mage8"

    @classmethod
    def default_parameters(cls):
        lst = super(Mage8, cls).default_parameters()

        # Insert new parameters at specific position
        names = list(map(lambda t: t[0], lst))
        i = names.index("mage_precision_reduction_factor_Q")
        lst.insert(i+1, ("mage_precision_reduction_factor_r", "1"))

        # Mage parameter for sediment module (added in DB 0.0.4)
        lst.append(("mage_sediment_masse_volumique", "2650.0"))
        lst.append(("mage_sediment_angle_repos", "40.0"))
        lst.append(("mage_sediment_porosity", "0.40"))
        lst.append(("mage_distance_Han", "0.0"))
        lst.append(("mage_distance_chargement_d50", "100.0"))
        lst.append(("mage_distance_chargement_sigma", "100.0"))
        lst.append(("mage_methode_modification_geometrie", "1"))
        lst.append(("mage_shields_critique", "1"))
        lst.append(("mage_shields_correction", "1"))
        lst.append(("mage_capacite_solide", "1"))
        lst.append(("mage_pas_de_temps_charriage", "1"))
        lst.append(("mage_facteur_multiplicateur", "1.0"))

        return lst

    ##########
    # Export #
    ##########

    def cmd_args(self, study):
        lst = super(Mage8, self).cmd_args(study)

        if study.river.has_sediment():
            lst.append("-c=3")

        return lst

    def _export_PAR(self, study, repertory, qlog=None, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export PAR file")

        with mage_file_open(os.path.join(repertory, f"{name}.PAR"), "w+") as f:
            files.append(f"{name}.PAR")

            params = study.river.get_params(self.type).parameters
            for p in params:
                name = p.name\
                        .replace("all_", "")\
                        .replace("mage_", "")
                value = p.value

                if name in ["command_line_arguments"]:
                    continue

                if name == "compute_reach_volume_balance":
                    value = "O" if value.lower() == "y" else "N"

                if name == "init_internal":
                    value = ("p" if value.lower() in ["y", "yes", "true", "o"]
                             else "")

                logger.debug(
                    f"export: PAR: {name}: {value} ({p.value})"
                )

                f.write(f"{name} {value}\n")

        return files

    def _export_NET(self, study, repertory, qlog=None, name="0"):
        files = []

        if qlog is not None:
            qlog.put("Export NET file")

        with mage_file_open(os.path.join(repertory, f"{name}.NET"), "w+") as f:
            files.append(f"{name}.NET")

            edges = study.river.enable_edges()

            for e in edges:
                name = self.get_reach_name(e)
                n1 = self.get_node_name(e.node1)
                n2 = self.get_node_name(e.node2)

                file = os.path.join("net", name + ".ST")

                f.write(f"{name} {n1} {n2} {file}\n")

        return files

    def _export_QSO(self, bounds, repertory, qlog, name="0"):
        files = []

        if len(bounds) == 0:
            return files

        if qlog is not None:
            qlog.put(f"Export QSO file")

        with mage_file_open(os.path.join(repertory, f"{name}.QSO"), "w+") as f:
            files.append(f"{name}.QSO")

            for bound in bounds:
                name = self.get_node_name(bound.node)
                f.write(f"* {bound.node.name} ({name}) {bound.bctype}\n")

                d50 = bound.d50
                sigma = bound.sigma

                if len(bound.data) == 0:
                    f.write(f"${name} {d50} {sigma} default\n")
                else:
                    f.write(f"${name} {d50} {sigma}\n")

                # Table header
                header = bound.header
                f.write(f"*{header[0]:>9}|{header[1]:>10}\n")

                # Data
                for d in bound.data:
                    f.write(f"{d[0]/60:10.3f}{d[1]:10.3f}\n")

        return files

    def export_func_dict(self):
        return [
            self._export_ST, self._export_PAR,
            self._export_NET, self._export_bound_cond,
            self._export_RUG, self._export_INI,
            self._export_SIN, self._export_VAR,
            self._export_CAS, self._export_DEV,
            self._export_lateral_contrib,
        ]

    @timer
    def export(self, study, repertory, qlog=None, name="0"):
        self._study = study
        name = study.name.replace(" ", "_")

        # Define GRA file name
        self._gra_file = f"{name}.GRA"
        self._bin_file = f"{name}.BIN"

        # Generate files
        files = []

        try:
            for func in self.export_func_dict():
                files = files + func(study, repertory, qlog, name=name)

            self.export_additional_files(study, repertory, qlog, name=name)
            self.export_study_description(study, repertory, qlog, name=name)
            self._export_REP(study, repertory, files, qlog, name=name)

            return True
        except Exception as e:
            logger.error(f"Failed to export study to {self._type}")
            logger_exception(e)
            return False

    ###########
    # RESULTS #
    ###########

    @timer
    def read_bin(self, study, fname, results, qlog=None, name="0"):
        logger.info(f"read_bin: Start reading '{fname}' ...")
        start = time.time()

        with mage_file_open(fname, "r") as f:
            def newline(): return np.fromfile(f, dtype=np.int32, count=1)
            def endline(): return np.fromfile(f, dtype=np.int32, count=1)

            def read_int(size): return np.fromfile(
                f, dtype=np.int32, count=size)

            def read_float(size): return np.fromfile(
                f, dtype=np.float32, count=size)

            def read_float64(size): return np.fromfile(
                f, dtype=np.float64, count=size)

            # Meta data (1st line)
            newline()

            data = read_int(3)

            nb_reach = data[0]
            nb_profile = data[1]
            mage_version = data[2]

            logger.debug(f"read_bin: nb_reach = {nb_reach}")
            logger.debug(f"read_bin: nb_profile = {nb_profile}")
            logger.debug(f"read_bin: mage_version = {mage_version}")

            if mage_version <= 80:
                msg = (
                    "Read BIN files: " +
                    f"Possible incompatible mage version '{mage_version}', " +
                    "please check your solver configuration..."
                )
                logger.warning(msg)

                if qlog is not None:
                    qlog.put("[WARNING] " + msg)

            results.set("solver_version", f"Mage8 ({mage_version})")
            results.set("nb_reach", f"{nb_reach}")
            results.set("nb_profile", f"{nb_profile}")

            endline()

            # Reach information (2nd line)
            newline()

            reachs = []
            iprofiles = []
            profile_len = []
            reach_offset = {}

            data = read_int(2*nb_reach)

            for i in range(nb_reach):
                # Add results reach to reach list
                r = results.river.add(i)
                reachs.append(r)

                # ID of first and last reach profiles
                i1 = data[2*i] - 1
                i2 = data[2*i+1] - 1

                # Keep profiles index of all river
                r.set_global_index(range(i1, i2 + 1))

                # Add profile id correspondance to reach
                for key in range(i1, i2 + 1):
                    iprofiles.append(i)

                # Profile ID offset
                reach_offset[r] = i1
                profile_len.append(i2 - i1 + 1)

            logger.debug(f"read_bin: iprofiles = {len(iprofiles)}")

            endline()

            # X (3rd line)
            newline()
            _ = read_float(nb_profile)
            endline()

            # Z and Y (4th line)
            newline()
            tmp = read_float(3*nb_profile)
            zfd = tmp[::3]
            endline()

            # Data
            newline()

            # check results and geometry consistency
            for i, r in enumerate(reachs):
                lp = len(r.profiles)
                pl = profile_len[i]
                if lp != pl:
                    logger.warning(f"geometry reach {i} has {lp} profiles")
                    logger.warning(f"results reach {i} has {pl} values")
                    return

            ts = set()
            tmp_table = {}

            end = False
            while not end:
                n = read_int(1)[0]
                timestamp = read_float64(1)[0]
                key = bytearray(np.fromfile(
                    f, dtype=np.byte, count=1)).decode()
                data = read_float(n)

                if key not in tmp_table:
                    tmp_table[key] = []

                tmp_table[key].append(data)
                ts.add(timestamp)

                if key in ["Z", "Q"]:
                    for i, d in enumerate(data):
                        # Get reach corresponding to profile ID
                        reach = reachs[iprofiles[i]]
                        # Get profile id in reach
                        ri = i - reach_offset[reach]

                        # Set data for profile RI
                        # reach.set(ri, timestamp, key, d)
                        if key == "Z":
                            profile = reach.profile(ri)
                            limits = profile.geometry.get_water_limits(d)
                            reach.set(
                                ri, timestamp,
                                "water_limits",
                                limits
                            )

                endline()
                end = newline().size <= 0

            ts_index = {}
            for i, t in enumerate(sorted(ts)):
                ts_index[t] = i
            results.set_timestamp_index(ts_index)

            table = {}
            for k in tmp_table:
                table[k] = np.array(tmp_table[k])

            results.set("timestamps", ts)
            ts_list = sorted(ts)
            logger.info(f"compute tab...")
            for r in reachs:
                for p in r.profiles:
                    p.geometry.compute_tabulation()

            logger.info(f"compute velocity...")

            velocity_arr = np.zeros((len(ts), len(iprofiles)))
            for ti, t in enumerate(ts_list):
                j = 0
                for r in reachs:
                    for pi, p in enumerate(r.profiles):
                        v = p.geometry.speed(
                            table["Q"][ti, p.global_index],
                            table["Z"][ti, p.global_index],
                        )
                        # r.set(pi, t, "V", v)
                        velocity_arr[ti, j] = v
                        j += 1

            table["V"] = velocity_arr

            for legend in table:
                table[legend] = results.new_table_data(
                    legend, table[legend]
                )

            results.set("table", table)

            logger.info(f"read_bin: ... end with {len(ts)} timestamp read")

        # results.bufferize("Z")
        # results.bufferize("Q")
        # results.bufferize("V")
        logger.info(f"reading time: '{time.time() - start}'")

    @timer
    def read_gra(self, study, repertory, results, qlog=None, name="0"):
        if not study.river.has_sediment():
            return

        fname = os.path.join(repertory, f"{name}.GRA")
        logger.info(f"read_gra: Start reading '{fname}' ...")
        start = time.time()

        with mage_file_open(fname, "r") as f:
            def newline(): return np.fromfile(f, dtype=np.int32, count=1)
            def endline(): return np.fromfile(f, dtype=np.int32, count=1)

            def read_int(size): return np.fromfile(
                f, dtype=np.int32, count=size)

            def read_float(size): return np.fromfile(
                f, dtype=np.float32, count=size)

            def read_float64(size): return np.fromfile(
                f, dtype=np.float64, count=size)

            # Meta data (1st line)
            newline()

            data = read_int(3)

            nb_reach = data[0]
            nb_profile = data[1]
            mage_version = data[2]

            logger.debug(f"read_gra: nb_reach = {nb_reach}")
            logger.debug(f"read_gra: nb_profile = {nb_profile}")
            logger.debug(f"read_gra: mage_version = {mage_version}")

            if mage_version < 80:
                msg = (
                    "Read GRA files: " +
                    f"Possible incompatible mage version '{mage_version}', " +
                    "please check your solver configuration..."
                )
                logger.warning(msg)

                if qlog is not None:
                    qlog.put("[WARNING] " + msg)

            results.set("gra_solver_version", f"Mage8 ({mage_version})")
            results.set("gra_nb_reach", f"{nb_reach}")
            results.set("gra_nb_profile", f"{nb_profile}")

            endline()

            # Reach information (2nd line)
            newline()

            reachs = []
            iprofiles = {}
            reach_offset = {}

            data = read_int(2*nb_reach)

            for i in range(nb_reach):
                # Get results reach to reach list
                r = results.river.reach(i)
                reachs.append(r)

                # ID of first and last reach profiles
                i1 = data[2 * i] - 1
                i2 = data[2 * i + 1] - 1

                # Add profile id correspondance to reach
                key = (i1, i2)
                iprofiles[key] = r

                # Profile ID offset
                reach_offset[r] = i1

            logger.debug(f"read_gra: iprofiles = {iprofiles}")

            endline()

            # X (3rd line)
            newline()
            _ = read_float(nb_profile)
            endline()

            # npts (4th line)
            newline()
            _ = read_int(nb_profile)
            endline()

            # Data
            def ip_to_r(i): return iprofiles[
                next(
                    filter(
                        lambda k: k[0] <= i <= k[1],
                        iprofiles
                    )
                )
            ]
            def ip_to_ri(r, i): return i - reach_offset[r]

            ts = set()
            end = False

            newline()
            while not end:
                n = read_int(1)[0]
                timestamp = read_float64(1)[0]
                with_bedload = read_int(1)[0]

                logger.debug(f"read_gra: Number of cross section: {n}")
                logger.debug(f"read_gra: Timestamp: {timestamp}")
                logger.debug(f"read_gra: Type of bedload: {with_bedload}")

                endline()

                npts = [1] * n
                if with_bedload == 1:
                    newline()
                    npts = read_int(n)
                    endline()

                sum_npts = sum(npts)
                logger.debug(f"read_gra: Number of points: {sum_npts}")

                newline()
                nsl = read_int(sum_npts)
                logger.debug(f"read_gra: Number of sedimentary layers: {nsl}")
                endline()

                newline()
                data = read_float64(sum(nsl) * 3)
                endline()

                ts.add(timestamp)

                i_pts = 0
                i_data = 0
                # Loop on cross sections
                for i in range(n):
                    sec_sl = []
                    reach = ip_to_r(i)
                    p_i = ip_to_ri(reach, i)

                    # Loop on cross section points
                    for j in range(npts[i]):
                        sl = []

                        # Loop on sediment layers
                        for k in range(nsl[i_pts]):
                            h = data[i_data]
                            d50 = data[i_data + 1]
                            sigma = data[i_data + 2]

                            sl.append((h, d50, sigma))
                            i_data += 3

                        i_pts += 1
                        sec_sl.append(sl)

                    reach.set(p_i, timestamp, "sl", sec_sl)

                logger.debug(
                    f"read_gra: data size = {len(data)} ({i_data} readed)"
                )
                end = newline().size <= 0

            ts_list = sorted(ts)

            logger.info(f"compute river bed elevation...")

            zfd_lst = []

            for r in reachs:
                z_min = r.geometry.get_z_min()
                sls = map(
                    lambda p: p.get_ts_key(ts_list[0], "sl")[0],
                    r.profiles
                )
                z_br = list(map(
                    lambda z, sl: reduce(
                        lambda z, h: z - h[0],
                        sl, z
                    ),
                    z_min,          # Original geometry
                    sls             # Original sediment layers
                ))
                for its, t in enumerate(ts_list):
                    sls = map(
                        lambda p: p.get_ts_key(t, "sl")[0],
                        r.profiles
                    )
                    zfd = list(map(
                        lambda z, sl: reduce(
                            lambda z, h: z + h[0],
                            sl, z
                        ),
                        z_br,       # bedrock
                        sls         # Original sediment layers
                    ))

                    zfd_lst.append(np.array(zfd))

            table = results.get("table")
            table["zfd"] = results.new_table_data(
                "zfd", np.array(zfd_lst)
            )

            results.set("sediment_timestamps", ts)
            logger.info(f"read_gra: ... end with {len(ts)} timestamp read")
        logger.debug(f"reading time: '{time.time() - start}'")

    @timer
    def results(self, study, repertory,
                qlog=None, name=None,
                with_gra=True):
        self._study = study
        if name is None:
            name = study.name.replace(" ", "_")

        results = super(Mage8, self).results(study, repertory, qlog, name=name)
        if results is None:
            return None

        if with_gra:
            self.read_gra(study, repertory, results, qlog, name=name)

        return results


class MageFake7(Mage8):
    _type = "mage_fake7"

    def __init__(self, name):
        super(MageFake7, self).__init__(name)

        self._type = "mage_fake7"

    @timer
    def results(self, study, repertory,
                qlog=None, name=None,
                with_gra=False):
        results = super(MageFake7, self).results(
            study, repertory,
            qlog, name=name, with_gra=with_gra
        )
        return results
