"""ContentsManager that allows to open Rmd, py, R and ipynb files as notebooks
"""
import os
from datetime import timedelta
import nbformat

try:
    import unittest.mock as mock
except ImportError:
    import mock
from tornado.web import HTTPError
from traitlets import Unicode, Float, Bool, Enum
from traitlets.config import Configurable

# import notebook.transutils before notebook.services.contents.filemanager #75
try:
    import notebook.transutils  # noqa
except ImportError:
    pass

from .jupytext import reads, writes
from .jupytext import create_prefix_dir as create_prefix_dir_from_path
from .combine import combine_inputs_with_outputs
from .formats import rearrange_jupytext_metadata, check_file_version
from .formats import NOTEBOOK_EXTENSIONS, long_form_one_format, long_form_multiple_formats
from .formats import short_form_one_format, short_form_multiple_formats
from .paired_paths import paired_paths, find_base_path_and_format, base_path, full_path, InconsistentPath
from .kernels import set_kernelspec_from_language


def preferred_format(incomplete_format, preferred_formats):
    """Return the preferred format for the given extension"""
    incomplete_format = long_form_one_format(incomplete_format)
    if 'format_name' in incomplete_format:
        return incomplete_format

    for fmt in long_form_multiple_formats(preferred_formats):
        if ((incomplete_format['extension'] == fmt['extension'] or (
                fmt['extension'] == '.auto' and
                incomplete_format['extension'] not in ['.md', '.markdown', '.Rmd', '.ipynb'])) and
                incomplete_format.get('suffix') == fmt.get('suffix', incomplete_format.get('suffix')) and
                incomplete_format.get('prefix') == fmt.get('prefix', incomplete_format.get('prefix'))):
            fmt.update(incomplete_format)
            return fmt

    return incomplete_format


def _jupytext_writes(fmt):
    def _writes(nbk, version=nbformat.NO_CONVERT, **kwargs):
        return writes(nbk, fmt, version=version, **kwargs)

    return _writes


def _jupytext_reads(fmt):
    def _reads(text, as_version, **kwargs):
        return reads(text, fmt, as_version=as_version, **kwargs)

    return _reads


def build_jupytext_contents_manager_class(base_contents_manager_class):
    """Derives a TextFileContentsManager class from the given base class"""

    class JupytextContentsManager(base_contents_manager_class, Configurable):
        """
        A FileContentsManager Class that reads and stores notebooks to classical
        Jupyter notebooks (.ipynb), R Markdown notebooks (.Rmd), Julia (.jl),
        Python (.py) or R scripts (.R)
        """

        # Dictionary: notebook path => (fmt, formats) where fmt is the current format, and formats the paired formats.
        paired_notebooks = dict()

        def all_nb_extensions(self):
            """All extensions that should be classified as notebooks"""
            return [ext if ext.startswith('.') else '.' + ext for ext in self.notebook_extensions.split(',')]

        default_jupytext_formats = Unicode(
            u'',
            help='Save notebooks to these file extensions. '
                 'Can be any of ipynb,Rmd,md,jl,py,R,nb.jl,nb.py,nb.R '
                 'comma separated. If you want another format than the '
                 'default one, append the format name to the extension, '
                 'e.g. ipynb,py:percent to save the notebook to '
                 'hydrogen/spyder/vscode compatible scripts',
            config=True)

        preferred_jupytext_formats_save = Unicode(
            u'',
            help='Preferred format when saving notebooks as text, per extension. '
                 'Use "jl:percent,py:percent,R:percent" if you want to save '
                 'Julia, Python and R scripts in the double percent format and '
                 'only write "jupytext_formats": "py" in the notebook metadata.',
            config=True)

        preferred_jupytext_formats_read = Unicode(
            u'',
            help='Preferred format when reading notebooks from text, per '
                 'extension. Use "py:sphinx" if you want to read all python '
                 'scripts as Sphinx gallery scripts.',
            config=True)

        default_notebook_metadata_filter = Unicode(
            u'',
            help="Cell metadata that should be save in the text representations. "
                 "Examples: 'all', '-all', 'widgets,nteract', 'kernelspec,jupytext-all'",
            config=True)

        default_cell_metadata_filter = Unicode(
            u'',
            help="Notebook metadata that should be saved in the text representations. "
                 "Examples: 'all', 'hide_input,hide_output'",
            config=True)

        comment_magics = Enum(
            values=[True, False],
            allow_none=True,
            help='Should Jupyter magic commands be commented out in the text representation?',
            config=True)

        split_at_heading = Bool(
            False,
            help='Split markdown cells on headings (Markdown and R Markdown formats only)',
            config=True)

        sphinx_convert_rst2md = Bool(
            False,
            help='When opening a Sphinx Gallery script, convert the reStructuredText to markdown',
            config=True)

        outdated_text_notebook_margin = Float(
            1.0,
            help='Refuse to overwrite inputs of a ipynb notebooks with those of a '
                 'text notebook when the text notebook plus margin is older than '
                 'the ipynb notebook',
            config=True)

        default_cell_markers = Unicode(
            u'',
            help='Start and end cell markers for the light format, comma separated. Use "{{{,}}}" to mark cells'
                 'as foldable regions in Vim, and "region,endregion" to mark cells as Vscode/PyCharm regions',
            config=True)

        notebook_extensions = Unicode(
            u','.join(NOTEBOOK_EXTENSIONS),
            help='A comma separated list of notebook extensions',
            config=True)

        def drop_paired_notebook(self, path):
            """Remove the current notebook from the list of paired notebooks"""
            if path not in self.paired_notebooks:
                return

            fmt, formats = self.paired_notebooks.pop(path)
            prev_paired_paths = paired_paths(path, fmt, formats)
            for alt_path, _ in prev_paired_paths:
                if alt_path in self.paired_notebooks:
                    self.drop_paired_notebook(alt_path)

        def update_paired_notebooks(self, path, fmt, formats):
            """Update the list of paired notebooks to include/update the current pair"""
            if not formats:
                self.drop_paired_notebook(path)
                return

            new_paired_paths = paired_paths(path, fmt, formats)
            for alt_path, _ in new_paired_paths:
                self.drop_paired_notebook(alt_path)

            long_formats = long_form_multiple_formats(formats)
            if len(long_formats) == 1 and set(long_formats[0]) <= {'extension'}:
                return

            short_formats = short_form_multiple_formats(formats)
            for alt_path, alt_fmt in new_paired_paths:
                self.paired_notebooks[alt_path] = short_form_one_format(alt_fmt), short_formats

        def set_default_format_options(self, format_options, read=False):
            """Set default format option"""
            if self.default_notebook_metadata_filter:
                format_options.setdefault('notebook_metadata_filter', self.default_notebook_metadata_filter)
            if self.default_cell_metadata_filter:
                format_options.setdefault('cell_metadata_filter', self.default_cell_metadata_filter)
            if self.comment_magics is not None:
                format_options.setdefault('comment_magics', self.comment_magics)
            if self.split_at_heading:
                format_options.setdefault('split_at_heading', self.split_at_heading)
            if not read and self.default_cell_markers:
                format_options.setdefault('cell_markers', self.default_cell_markers)
            if read and self.sphinx_convert_rst2md:
                format_options.setdefault('rst2md', self.sphinx_convert_rst2md)

        def default_formats(self, path):
            """Return the default formats, if they apply to the current path #157"""
            formats = long_form_multiple_formats(self.default_jupytext_formats)
            for fmt in formats:
                try:
                    base_path(path, fmt)
                    return self.default_jupytext_formats
                except InconsistentPath:
                    continue

            return None

        def create_prefix_dir(self, path, fmt):
            """Create the prefix dir, if missing"""
            create_prefix_dir_from_path(self._get_os_path(path.strip('/')), fmt)

        def save(self, model, path=''):
            """Save the file model and return the model with no content."""
            if model['type'] != 'notebook':
                return super(JupytextContentsManager, self).save(model, path)

            nbk = model['content']
            try:
                metadata = nbk.get('metadata')
                rearrange_jupytext_metadata(metadata)
                jupytext_metadata = metadata.setdefault('jupytext', {})
                jupytext_formats = jupytext_metadata.get('formats') or self.default_formats(path)

                if not jupytext_formats:
                    text_representation = jupytext_metadata.get('text_representation', {})
                    ext = os.path.splitext(path)[1]
                    fmt = {'extension': ext}

                    if ext == text_representation.get('extension') and text_representation.get('format_name'):
                        fmt['format_name'] = text_representation.get('format_name')

                    jupytext_formats = [fmt]

                jupytext_formats = long_form_multiple_formats(jupytext_formats, metadata,
                                                              auto_ext_requires_language_info=False)

                # Set preferred formats if not format name is given yet
                jupytext_formats = [preferred_format(f, self.preferred_jupytext_formats_save) for f in jupytext_formats]

                base, fmt = find_base_path_and_format(path, jupytext_formats)
                self.update_paired_notebooks(path, fmt, jupytext_formats)
                self.set_default_format_options(jupytext_metadata)

                if not jupytext_metadata:
                    metadata.pop('jupytext')

                # Save as ipynb first
                return_value = None
                value = None
                for fmt in jupytext_formats[::-1]:
                    if fmt['extension'] != '.ipynb':
                        continue

                    alt_path = full_path(base, fmt)
                    self.create_prefix_dir(alt_path, fmt)
                    self.log.info("Saving %s", os.path.basename(alt_path))
                    value = super(JupytextContentsManager, self).save(model, alt_path)
                    if alt_path == path:
                        return_value = value

                # And then to the other formats, in reverse order so that
                # the first format is the most recent
                for fmt in jupytext_formats[::-1]:
                    if fmt['extension'] == '.ipynb':
                        continue

                    alt_path = full_path(base, fmt)
                    self.create_prefix_dir(alt_path, fmt)
                    if 'format_name' in fmt and fmt['extension'] not in ['.md', '.markdown', '.Rmd']:
                        self.log.info("Saving %s in format %s:%s",
                                      os.path.basename(alt_path), fmt['extension'][1:], fmt['format_name'])
                    else:
                        self.log.info("Saving %s", os.path.basename(alt_path))
                    with mock.patch('nbformat.writes', _jupytext_writes(fmt)):
                        value = super(JupytextContentsManager, self).save(model, alt_path)
                        if alt_path == path:
                            return_value = value

                # Update modified timestamp to match that of the pair #207
                return_value['last_modified'] = value['last_modified']
                return return_value

            except Exception as err:
                raise HTTPError(400, str(err))

        def get(self, path, content=True, type=None, format=None, load_alternative_format=True):
            """ Takes a path for an entity and returns its model"""
            os_path = self._get_os_path(path.strip('/'))
            ext = os.path.splitext(path)[1]

            # Not a notebook?
            if (not self.exists(path) or os.path.isdir(os_path) or
                    (type != 'notebook' if type else ext not in self.all_nb_extensions())):
                return super(JupytextContentsManager, self).get(path, content, type, format)

            fmt = preferred_format(ext, self.preferred_jupytext_formats_read)
            if ext == '.ipynb':
                model = self._notebook_model(path, content=content)
            else:
                self.set_default_format_options(fmt, read=True)
                with mock.patch('nbformat.reads', _jupytext_reads(fmt)):
                    model = self._notebook_model(path, content=content)

            if not load_alternative_format:
                return model

            if not content:
                # Modification time of a paired notebook, in this context - Jupyter is checking timestamp
                # before saving - is the most recent among all representations #118
                if path not in self.paired_notebooks:
                    return model

                fmt, formats = self.paired_notebooks.get(path)
                for alt_path, _ in paired_paths(path, fmt, formats):
                    if alt_path != path and self.exists(alt_path):
                        alt_model = self._notebook_model(alt_path, content=False)
                        if alt_model['last_modified'] > model['last_modified']:
                            model['last_modified'] = alt_model['last_modified']

                return model

            # We will now read a second file if this is a paired notebooks.
            nbk = model['content']
            jupytext_formats = nbk.metadata.get('jupytext', {}).get('formats') or self.default_formats(path)
            jupytext_formats = long_form_multiple_formats(jupytext_formats, nbk.metadata,
                                                          auto_ext_requires_language_info=False)

            # Compute paired notebooks from formats
            alt_paths = [(path, fmt)]
            if jupytext_formats:
                try:
                    _, fmt = find_base_path_and_format(path, jupytext_formats)
                    alt_paths = paired_paths(path, fmt, jupytext_formats)
                    self.update_paired_notebooks(path, fmt, jupytext_formats)
                except InconsistentPath as err:
                    self.log.info("Unable to read paired notebook: %s", str(err))
            else:
                if path in self.paired_notebooks:
                    fmt, formats = self.paired_notebooks.get(path)
                    alt_paths = paired_paths(path, fmt, formats)

            if len(alt_paths) > 1 and ext == '.ipynb':
                # Apply default options (like saving and reloading would do)
                jupytext_metadata = model['content']['metadata'].get('jupytext', {})
                self.set_default_format_options(jupytext_metadata, read=True)
                if jupytext_metadata:
                    model['content']['metadata']['jupytext'] = jupytext_metadata

            org_model = model
            fmt_inputs = fmt
            path_inputs = path_outputs = path
            model_outputs = None

            # Source format is the most recent non ipynb format found on disk
            if path.endswith('.ipynb'):
                source_timestamps = {}
                for alt_path, alt_fmt in alt_paths:
                    if not alt_path.endswith('.ipynb') and self.exists(alt_path):
                        source_timestamps[alt_path] = self._notebook_model(alt_path, content=False)['last_modified']

                most_recent_timestamp = None
                for alt_path in source_timestamps:
                    alt_ts = source_timestamps[alt_path]
                    if len(source_timestamps) > 1:
                        self.log.info(u'File {} was last modified at {}'.format(alt_path, alt_ts))
                    if most_recent_timestamp is None or alt_ts > most_recent_timestamp:
                        most_recent_timestamp = alt_ts
                        model_outputs = model
                        path_inputs = alt_path
                        fmt_inputs = alt_fmt

                if most_recent_timestamp is not None:
                    self.log.info(u'Reading SOURCE from {}'.format(path_inputs))
                    model = self.get(path_inputs, content=content, type='notebook', format=format,
                                     load_alternative_format=False)

            # Outputs taken from ipynb if in group, if file exists
            else:
                for alt_path, _ in alt_paths:
                    if alt_path.endswith('.ipynb') and self.exists(alt_path):
                        self.log.info(u'Reading OUTPUTS from {}'.format(alt_path))
                        path_outputs = alt_path
                        model_outputs = self.get(alt_path, content=content, type='notebook', format=format,
                                                 load_alternative_format=False)
                        break

            try:
                check_file_version(model['content'], path_inputs, path_outputs)
            except Exception as err:
                raise HTTPError(400, str(err))

            # Before we combine the two files, we make sure we're not overwriting ipynb cells
            # with an outdated text file
            try:
                if model_outputs and model_outputs['last_modified'] > model['last_modified'] + \
                        timedelta(seconds=self.outdated_text_notebook_margin):
                    raise HTTPError(
                        400,
                        '''{out} (last modified {out_last})
                        seems more recent than {src} (last modified {src_last})
                        Please either:
                        - open {src} in a text editor, make sure it is up to date, and save it,
                        - or delete {src} if not up to date,
                        - or increase check margin by adding, say,
                            c.ContentsManager.outdated_text_notebook_margin = 5 # in seconds # or float("inf")
                        to your .jupyter/jupyter_notebook_config.py file
                        '''.format(src=path_inputs, src_last=model['last_modified'],
                                   out=path_outputs, out_last=model_outputs['last_modified']))
            except OverflowError:
                pass

            if model_outputs:
                combine_inputs_with_outputs(model['content'], model_outputs['content'], fmt_inputs)
            elif not path.endswith('.ipynb'):
                set_kernelspec_from_language(model['content'])

            # Trust code cells when they have no output
            for cell in model['content'].cells:
                if cell.cell_type == 'code' and not cell.outputs and cell.metadata.get('trusted') is False:
                    cell.metadata['trusted'] = True

            # Path and name of the notebook is the one of the original path
            model['path'] = org_model['path']
            model['name'] = org_model['name']

            return model

        def trust_notebook(self, path):
            """Trust the current notebook"""
            if path.endswith('.ipynb') or path not in self.paired_notebooks:
                super(JupytextContentsManager, self).trust_notebook(path)
                return

            fmt, formats = self.paired_notebooks[path]
            for alt_path, alt_fmt in paired_paths(path, fmt, formats):
                if alt_fmt['extension'] == '.ipynb':
                    super(JupytextContentsManager, self).trust_notebook(alt_path)

        def rename_file(self, old_path, new_path):
            """Rename the current notebook, as well as its alternative representations"""
            if old_path not in self.paired_notebooks:
                try:
                    # we do not know yet if this is a paired notebook (#190)
                    # -> to get this information we open the notebook
                    self.get(old_path, content=True)
                except Exception:
                    pass

            if old_path not in self.paired_notebooks:
                super(JupytextContentsManager, self).rename_file(old_path, new_path)
                return

            fmt, formats = self.paired_notebooks.get(old_path)
            old_alt_paths = paired_paths(old_path, fmt, formats)

            # Is the new file name consistent with suffix?
            try:
                new_base = base_path(new_path, fmt)
            except Exception as err:
                raise HTTPError(400, str(err))

            for old_alt_path, alt_fmt in old_alt_paths:
                new_alt_path = full_path(new_base, alt_fmt)
                if self.exists(old_alt_path):
                    super(JupytextContentsManager, self).rename_file(old_alt_path, new_alt_path)

            self.drop_paired_notebook(old_path)
            self.update_paired_notebooks(new_path, fmt, formats)

    return JupytextContentsManager


try:
    from notebook.services.contents.largefilemanager import LargeFileManager

    TextFileContentsManager = build_jupytext_contents_manager_class(LargeFileManager)
except ImportError:
    # Older versions of notebook do not have the LargeFileManager #217
    from notebook.services.contents.filemanager import FileContentsManager

    TextFileContentsManager = build_jupytext_contents_manager_class(FileContentsManager)
