import os
import re
import yaml

from icalendar import Event

from libzet.Attributes import Attributes
from libzet.parse_duration import parse_duration


rst_sep = '.. end-zettel'
md_sep = '<!--- end-zettel --->'


# Find heading points.
def _is_rst_heading(s):
    s = s.strip()
    return s.startswith('=') and s.endswith('=')


def _is_md_heading(s):
    s = s.strip()
    return s.startswith('# ') or s.startswith('## ')


def get_zettels_from_md(md):
    """ Parse many zettels out of markdown text.

    Zettels are separated by md_sep

    Args:
        md: MD text or file to parse.

    Returns:
        List of zettels parsed from the string.
    """
    if os.path.exists(md):
        with open(md) as f:
            md = f.read()

    md = md.strip()
    if not md:
        return []

    # Markdown zettels are terminated by a line containing only this string.
    texts = md.split(md_sep)
    if not texts[-1]:
        texts.pop()

    zettels = []

    for t in texts:
        zettels.append(Zettel.createFromMd(t))

    return zettels


def get_zettels_from_rst(rst):
    if os.path.exists(rst):
        with open(rst) as f:
            rst = f.read()

    rst = rst.strip()
    if not rst:
        return []

    texts = rst.split(rst_sep)
    if not texts[-1]:
        texts.pop()

    zettels = []
    for t in texts:
        zettels.append(Zettel.createFromRst(t))

    return zettels


def str_to_zettels(text, zettel_format):
    """ Convert a str to a list of zettels.

    The return from this function can be passed to zettels_to_str.

    Args:
        text: Text to convert to zettels.
        zettel_format: 'rst' or 'md'.

    Returns:
        A list of Zettel references.
    """
    zettels = []
    if zettel_format == 'rst':
        zettels = get_zettels_from_rst(text)
    elif zettel_format == 'md':
        zettels = get_zettels_from_md(text)

    return zettels


def zettels_to_str(zettels, zettel_format, headings=None):
    """ Return many zettels as a str.

    The output from this function can be passed to str_to_zettels.

    Args:
        zettels: List of zettels to print.
        zettel_format: 'rst' or 'md'.

    Returns:
        A str representing the zettels
    """
    text = ''
    formats = ['rst', 'md']

    if zettel_format not in formats:
        raise ValueError(f'zettel_format must be in {formats}')

    if zettel_format == 'rst':
        text = [x.getRst(headings) for x in zettels]
        text = f'\n{rst_sep}\n\n\n'.join(sorted([x for x in text if x]))
    elif zettel_format == 'md':
        text = [x.getMd(headings) for x in zettels]
        text = f'\n{md_sep}\n\n\n'.join(sorted([x for x in text if x]))

    return text


def parse_rrule(rrule):
    """ Parse an icalendar rrule str into a dict.

    Follow the ICS convention for rrules.

    https://icalendar.org/iCalendar-RFC-5545/3-8-5-3-recurrence-rule.html

    But omit the leading "RRULE:".

    Use the return of this function like this.

        event['rrule'] = parse_rrule(some_rrule))

    Arg:
        rrule: The str rrule to parse.

    Returns:
        An rrule object that can be directly set as an icalendar.Event's
        'rrule' index.

    Raises:
        ValueError if the rrule str wasn't valid.
    """
    rrule = 'RRULE:' + rrule
    s = '\n'.join(['BEGIN:VEVENT', rrule, 'END:VEVENT'])
    event = Event.from_ical(s)

    if not len(event['rrule']):
        raise ValueError('Invalid rrule')

    return event['rrule']


def filtered_zettels(zettels, filter='', letter='z'):
    """ Return a filtered list of zettels.

    Args:
        zettels: A list of zettels to filter on.
        filter: String to filter on. t is the zettel reference for each t
            in zettels. This filter must follow python3 conditional syntax.
        letter: What letter should be used to point to a zettel in the under-
            the-hood python3 list comprehension. Default is 'z'.

            eg. [z for z in zettels if eval(filter)]

    Returns:
        A filtered list of zettel references which matched the filter.
    """
    filter = filter or 'True'

    s = f'[{letter} for {letter} in zettels if {filter}]'
    return eval(s)


class Zettel:
    """ Represents a zettel.
    """
    def __init__(self, title, headings, attributes=None, **kwargs):
        """ Init a new Zettel.

        Args:
            title: Title of the zettel.
            headings: Dictionary of subheadings. Each heading is paired with
                a string for its zettel.

            attributes: Metadata about the zettel.

                Attributes may also be provided to the constructor via
                keyword arguments. Tasks may have additional attributes.

        Raises:
            ValueError if some attribute keys were invalid.
        """
        attributes = attributes or dict()

        self.title = title
        self.headings = {k: v for k, v in headings.items()}

        # Process attributes
        self.attributes = Attributes()
        self.attributes.update(attributes)
        self.attributes.update(kwargs)

    def asIcsEvent(self, uid):
        """ Return an icalendar.Event class from this Zettel's data.

        A zettel must have either a event_begin or a due_date to be
        considered capable of turning into an ICS Event.

        This function does not support all ICS Event values. The
        supported values are...
        - uid
        - dtstamp: Initialized to the time this method was called.
        - summary
        - description
        - dtstart: as event_begin or due_date with respective *_time's
        - dtend: as event_end + end_time (optional)
        - rrule: as recurring

        Returns:
            An icalendar.Event created from this instance's data. If
            this zettel doesn't have a due_date or event_begin field then
            None will be returned.
        """
        exp = ['event_begin', 'due_date']
        if not any([x in self.attributes and self.attributes[x] for x in exp]):
            return None

        desc = self.headings['notes'] if 'notes' in self.headings else ''

        event = Event()
        event.add('uid', uid)
        event.add('summary', self.title)
        event.add('description', desc)

        event.add('dtstamp', parse_date('today'))

        if 'event_begin' in self and self.event_begin:
            event.add('dtstart', self.event_begin.date)
        elif 'due_date' in self and self.due_date:
            event.add('dtstart', self.due_date.date)

        # recurring zettels should also have event_begin
        if 'recurring' in self and self.recurring:
            rrule = self.recurring

            if 'recurring_stop' in self and self.recurring_stop:
                rrule += f';until={self.recurring_stop.asRrule()}'

            event['rrule'] = parse_rrule(rrule)

        # Add duration.
        if 'duration' in self and self.duration:
            event.add('duration', parse_duration(self.duration))

        # Parse dtend
        if 'event_end' in self and self.event_end:
            event.add('dtend', self.event_end.date)

        return event

    @classmethod
    def createBlankZettel(cls, title):
        """ Create a blank zettel. Still needs a title.

        Args:
        title: Title of the zettel.
        """
        return Zettel(title, dict(), dict())

    @classmethod
    def createFromMd(cls, md):
        """ Create a new Zettel from markdown text.

        The text will be parsed to derive key-value zettels where the key
        is the heading and the value is the subsequent text.

        The title of the zettel is expected to be a markdown level-1 heading
        (# ) This is followed by the zettel's notes, which is then
        succeeded by the rest of the zettel's headings, which are expected
        to be RST level 2 headings (## ).

        Args:
            md: Markdown text or filename from which to create the zettel.

        Returns:
            A new Zettel.

        Raises:
            ValueError if the markdown could not create a valid Zettel.
        """
        if os.path.exists(md):
            with open(md) as f:
                md = f.read()

        md = md.strip()
        if not md:
            raise ValueError('No text provided')

        ptr = 0

        title = ''
        headings = {}

        attr_header = '<!--- attributes --->'
        md = md.split(attr_header)
        if len(md) == 1:
            md.append('')

        # split out attributes and actual zettel text
        attributes = yaml.safe_load(md[-1].strip())
        md = attr_header.join(md[:-1]).splitlines()

        # Find the headings and content.
        heading_pts = [x for x, y in enumerate(md) if _is_md_heading(y)]

        # Find the title.
        if not heading_pts:
            raise ValueError('No title in zettel.')

        heading_pts.append(len(md))

        title = ' '.join(md[heading_pts[0]].split()[1:])

        # Gather notes
        headings['notes'] = '\n'.join(md[1:heading_pts[1]])

        # ... and delete the "notes" zettel if no notes were entered.
        if not headings['notes'].strip():
            del(headings['notes'])

        # Get the rest
        for ptr in range(1, len(heading_pts) - 1):
            heading = ''.join(md[heading_pts[ptr]].split('##')[1:]).strip()
            content_start = heading_pts[ptr] + 1
            content_end = heading_pts[ptr + 1]
            headings[heading] = '\n'.join(md[content_start:content_end])

        return Zettel(title, headings, attributes)

    @classmethod
    def createFromRst(cls, rst):
        """ Create a new Zettel from RST text

        The title is expected to be an RST level-1 heading (=====). This
        is followed by the zettel's description.

        At the end of the description are the attributes. These are single
        line attributes which contain metadata about the zettel.

            ============================
             PROJ-77: My zettel's title
            ============================
            My zettels's description

            Other heading
            =============
            My other heading description

            .. attributes
            creator: OneRedDime 
            assignee: OneRedDime
            creation_date: 03/01/2023

        Args:
            rst: RST-formatted text from which to create the zettel. This value
                be provided as a string of RST formatted text or a filename.

        Returns:
            A new Zettel.

        Raises:
            ValueError: If the RST could not create a valid Zettel.
        """
        if os.path.exists(rst):
            with open(rst) as f:
                rst = f.read()

        rst = rst.strip()
        if not rst:
            raise ValueError('No text provided')

        ptr = 0

        title = ''
        headings = {}

        # Split out the attributes and content.
        attr_header = '.. attributes\n::'
        rst = rst.split(attr_header)
        if len(rst) == 1:
            rst.append('')

        attributes = yaml.safe_load(rst[-1].strip())
        rst = attr_header.join(rst[:-1]).splitlines()

        # Strip the first line of === if it exists.
        if _is_rst_heading(rst[0]):
            rst = rst[1:]

        # Find the lines in the text that are heading markers.
        heading_pts = [x - 1 for x, y in enumerate(rst) if _is_rst_heading(y)]

        # First heading marker is the title.
        if not heading_pts or heading_pts[0] == -1:
            raise ValueError('No title in zettel.')

        heading_pts.append(len(rst))

        title = rst[heading_pts[0]].strip()

        # Gather notes
        headings['notes'] = '\n'.join(rst[heading_pts[0] + 2:heading_pts[1]])

        # ... and delete the "notes" heading if no notes were entered.
        if not headings['notes'].strip():
            del(headings['notes'])

        # Get the rest
        for ptr in range(1, len(heading_pts) - 1):
            heading = rst[heading_pts[ptr]]
            content_start = heading_pts[ptr] + 2
            content_end = heading_pts[ptr + 1]
            headings[heading] = '\n'.join(rst[content_start:content_end])

        return Zettel(title, headings, attributes)

    def refresh(self):
        """ Make minor corrections

        Tags should be sorted.
        """
        self.attributes['tags'] = sorted(list(set([x for x in self.attributes['tags']])))

    def update(self, new_zettel, exp_headings=None):
        """ Update a zettel.

        The title, headings, and attributes of this zettel will be
        replaced with the ones in new_zettel.

        If exp_headings=None, then it would be as if the whole body text
        is replaced.

        But if exp_headings is provided, then only those headings will be
        be expected to be in the new zettel. If one of those is missing
        then it will be deleted while others will be ignored.

        Args:
            new_zettel: Other zettel instance whose attributes will be used.
            exp_headings: If a heading is listed here, but is not found in
                the final text, then the heading will be deleted.

        Raises:
            See createFromRst.
        """
        exp_headings = exp_headings or []

        # Update this zettel.
        self.title = new_zettel.title
        self.attributes = new_zettel.attributes.copy()

        if not exp_headings:
            self.headings = new_zettel.headings
        else:
            for heading in exp_headings:
                if heading not in new_zettel.headings and heading in self.headings:
                    del(self.headings[heading])

            self.headings.update(new_zettel.headings)

        self.refresh()

    def getMd(self, headings=None, force=False):
        """ Display this zettel in MD format.

        Args:
            headings: Only display these headings.
            force: Display even there would be no meaningful text.

        Returns:
            A markdown string representing the content of this zettel.
        """
        display = False

        headings = headings or []
        headings = headings or self.headings
        headings = [x.lower() for x in headings]

        s = []
        s.append('# ' + self.title)

        if 'notes' in headings:
            display = True
            s.append(self.headings['notes'].rstrip())
            s.append('')

        # case-insensitive search
        lookup_headings = {k.lower(): k for k in self.headings}
        for heading in headings:
            if heading == 'notes' or heading not in lookup_headings:
                continue

            display = True
            s.append('## ' + lookup_headings[heading])
            s.append(self.headings[lookup_headings[heading]].rstrip())
            s.append('')

        # No content, but add an empty line for good-looks
        if not display and force:
            s.append('')

        # Append the attributes
        s.append('<!--- attributes --->')
        s += ['    ' + x for x in str(self.attributes).splitlines()]
        s.append('')

        if display or force:
            return '\n'.join(s)
        else:
            return ''

    def getRst(self, headings=None, force=False):
        """ Display this zettel in RST format.

        Returns:
            An RST string representing the content of this zettel.
        """
        display = False

        headings = headings or []
        headings = headings or self.headings
        headings = [x.lower() for x in headings]

        s = []
        s.append('=' * (len(self.title) + 2))
        s.append(' ' + self.title)
        s.append('=' * (len(self.title) + 2))

        if 'notes' in headings:
            display = True
            s.append(self.headings['notes'].rstrip())
            s.append('')

        # case-insensitive search
        lookup_headings = {k.lower(): k for k in self.headings}
        for heading in headings:
            if heading == 'notes' or heading not in lookup_headings:
                continue

            display = True
            s.append(lookup_headings[heading])
            s.append('=' * len(lookup_headings[heading]))
            s.append(self.headings[lookup_headings[heading]].rstrip())
            s.append('')

        # No content, but add an empty line for good-looks
        if not display and force:
            s.append('')

        # Append the attributes
        s.append('.. attributes')
        s.append('::')
        s.append('')
        s += ['    ' + x for x in str(self.attributes).splitlines()]

        # Will become trailing newline
        s.append('')

        if display or force:
            return '\n'.join(s)
        else:
            return ''

    def __eq__(self, o):
        """ Returns true if o.title matches this one.
        """
        return self.title == o.title

    def __lt__(self, o):
        """ Returns true if o.title is less than this one.
        """
        return self.title < o.title

    def __getattr__(self, key):
        """ Expose keys in self.attributes as attributes.
        """
        if key in self.__dict__:
            return self.__dict__[key]

        return self.attributes[key]

    def __contains__(self, key):
        """ Return True if key is in the Zettel's dict or attributes.
        """
        if key in self.__dict__:
            return True

        return key in self.attributes
