from contextlib import contextmanager
from functools import wraps
import math
from networkx import DiGraph, reverse_view
from typing import Any, Callable, List, Literal, Optional, Self, Sequence, Set, TextIO

from yarnover.formatter import MarkdownFormatter
from yarnover.pattern import Comment, Instruction, InstructionGroup, RepeatedInstructionGroup, BoundedStitchRepeat, MarkerAction, Pattern, Row, RepeatedRowGroup, RowGroup, Section, NamedInstructionGroup, Stitches, Break
from yarnover.exceptions import KnittingException, PatternException
from yarnover.needle import Needle, BackNeedle, Stitch, Marker

import logging

from yarnover.swatch import Swatch

logger = logging.getLogger(__name__)

class StitchDetails:
    def __init__(self,
                 code : str,
                 parent_count : int = 1,
                 slip_from_left_needle : bool = True,
                 add_to_right_needle : bool = True):
        self.code = code
        self.parent_count = parent_count
        self.slip_from_left_needle = slip_from_left_needle
        self.add_to_right_needle = add_to_right_needle

class Builder:
    def __init__(self, title, **kwargs):
        self.left = Needle()
        self.right = Needle()
        self.g : DiGraph = DiGraph()

        self.next_stitch_number : int = 0
        self.last_stitch_worked : int | None = None

        self.row_number = 0
        self.stitch_number = 0
        self.initial_stitch_count = 0

        self.pattern = Pattern(title)
        self.scopes : List[Any] = [self.pattern]
        self.auto_break = kwargs.get("auto_break", -1)

    @property
    def scope(self):
        return self.scopes[-1]

    def push_scope(self, scope):
        self.scope.items.append(scope)
        self.scopes.append(scope)

    def pop_scope(self, scope=None):
        """Pop scopes until `scope` is the current scope, then pop that.
        """
        if scope:
            while self.scopes and self.scope is not scope:
                self.scopes.pop()
        self.scopes.pop()
        
    def ensure_section_scope(self):
        if isinstance(self.scope, Pattern):
            self.push_scope(Section())

    def ensure_row_scope(self) -> InstructionGroup:
        self.ensure_section_scope()
        if isinstance(self.scope, (Section, RowGroup)):
            self.push_scope(Row(self.row_number))
        assert isinstance(self.scope, InstructionGroup)
        return self.scope

    def work_stitch(self, details : StitchDetails):
        """
        Adds a stitches to the work.

        :param code: The code of the new stitch.
        :param parent_count: The number of stitches from the left needle to work.
        :param slip_from_left_needle: Whether to slip the parent stitches from the left needle
        :param add_to_right_needle: Whether to add the new stitch to the right needle
        :meta private:
        """
        stitch = self.next_stitch_number

        self.g.add_node(
            stitch,
            details=details,
            row_number=self.row_number,
            stitch_number=self.stitch_number,
        )

        if self.last_stitch_worked is not None:
            self.g.add_edge(self.last_stitch_worked, stitch, relation="successor")
            
        self.last_stitch_worked = stitch
        self.next_stitch_number += 1
        self.stitch_number += 1 # on row

        if details.parent_count > 0:
            for i in range(details.parent_count):
                parent_stitch = self.left.peek(i)
                if not isinstance(parent_stitch, Stitch):
                    raise KnittingException(f"Trying to work non-stitch item: {parent_stitch}")
                self.g.add_edge(stitch, parent_stitch, relation="parent")

        if details.slip_from_left_needle:
            self.left.pop(details.parent_count)
        if details.add_to_right_needle:
            self.right.push(stitch)

    def work_stitches(self, details : StitchDetails, count : int = 1):
        """
        Convenience function to work the same stitch several times.

        :param code: The code of the new stitch.
        :param count: The number of stitches to work.
        :meta private:
        """
        logger.debug(f"{self.row_number}.{self.stitch_number}: {details.code}*{count}")
        for _ in range(count):
            self.work_stitch(details)
        scope = self.ensure_row_scope()
        scope.add_item(Stitches(details.code, count))

    def stitch_to_end(self,
                      details : StitchDetails,
                      stitches_left : int = 0,
                      log_count : bool = True,
                      marker_action : Literal["fail", "slip", "remove"] = "fail") -> None:
        """
        Repeat a simple stitch until all the stitches on the needle have been
        worked and all markers slipped or removed.

        :param code: The code of the stitch.
        :param stitches_left: The number of stitches that should be left unworked before the end.
        :param marker_action: What to do when a marker is encountered - "fail", "slip" or "remove".
        :meta private:
        """
        if stitches_left < 0:
            stitches_left = -stitches_left
        row = self.ensure_row_scope()
        repeat = BoundedStitchRepeat(
            f"{details.code}te",
            stitches_left,
            count=0,
            log_count=log_count)
        row.items.append(repeat)
        self.scopes.append(repeat)
        blocks = self.summarize()
        while len(blocks) > 1:
            block = blocks.pop(0)
            repeat.count += block
            self.work_stitches(details, block)
            if marker_action == "slip":
                self.sm()
            elif marker_action == "remove":
                self.rm()
            else:
                raise KnittingException("Marker before end")
        block = blocks.pop(0)
        if block < stitches_left:
            raise KnittingException(f"Less than {stitches_left} left")
        self.work_stitches(details, block-stitches_left)
        repeat.count += block-stitches_left
        self.scopes.pop()
        
    def stitch_to_marker(self,
                         details : StitchDetails,
                         stitches_left : int = 0,
                         log_count : bool = True,
                         ):
        """
        Repeat a stitch until all the stitches until the next marker is reached

        :param code: The code of the stitch.
        :param stitches_left: The number of stitches that should be left unworked.
        :param log_count: 
        :param parent_count: 
        :param slip_from_left_needle: 
        :param slip_from_left_needle: 
        
        :meta private:
        """
        if stitches_left < 0:
            stitches_left = -stitches_left
        self.ensure_row_scope()
        group = BoundedStitchRepeat(
            f"{details.code}tm",
            stitches_left,
            count=0,
            log_count=log_count
        )
        self.push_scope(group)

        distance = self.left.distance_to_marker()
        if distance < stitches_left:
            raise KnittingException(f"Less than {stitches_left} stitches left to marker")
        group.count = distance-stitches_left
        self.work_stitches(details, group.count)
        self.scopes.pop()

    # Markers
    def at_marker(self, stitches_left : int = 0) -> bool:
        """
        Return True iff the next item on the left needle is a marker.

        :param stitches_left: The number of stitches ahead the marker must be.
        :meta private:
        """
        if self.left.is_empty:
            return False
        next_item = self.left.peek(stitches_left)
        return isinstance(next_item, Marker)

    # Distances
    def at_end(self, stitches_left : int = 0):
        if self.left.is_circular:
            raise KnittingException(f"Circular needle has no end")
        distance = self.left.distance_to_end()
        if distance < stitches_left:
            raise KnittingException(f"Less than {stitches_left} stitches left")
        return distance == stitches_left

    def distance_to_marker(self, stitches_left : int = 0):
        """
        Returns the number of stitches left before the next marker.
        There must be at least one marker left on the needle.
        If `stitches_left` is provided, this will return the number of stitches before
        that number of stitches are left.
        
        :meta private:
        """
        distance = self.left.distance_to_marker()
        if distance < stitches_left:
            raise KnittingException(f"Less than {stitches_left} stitches left before marker")
        return distance - stitches_left
        
    # Stitches
    def co(self, count : int = 1):
        """
        Cast on one or more stitches.
        """
        self.work_stitches(StitchDetails(code="co", parent_count=0), count=count)
        return self

    def s(self, count : int = 1):
        """
        Slip one or more stitches from the left needle to the right.

        :raise KnittingException: If there are no stitches to slip.
        """
        if count < 0:
            return self.back_slip(-count)
        for _ in range(count):
            if self.left.is_empty:
                raise KnittingException(f"No more stitches to work")
            if not isinstance(self.left.peek(), Stitch):
                raise KnittingException(f"Next item is not a stitch: {self.left.peek()}")
            self.right.extend(self.left.pop())
        self.ensure_row_scope()
        self.scope.add_item(Stitches("s", count))
        return self

    def back_slip(self, count : int):
        """
        Slip one or more stitches from the right needle to the left.

        :raise KnittingException: If there are no stitches to slip.
        """
        for _ in range(count):
            if self.right.is_empty:
                raise KnittingException(f"No more stitches to work")
            if not isinstance(self.right.peek(), Stitch):
                raise KnittingException(f"Next item is not a stitch: {self.right.peek()}")
            self.left.extend(self.right.pop())
        self.ensure_row_scope()
        self.scope.add_item(Stitches("s", -count))
        return self

    def k(self, count : int = 1):
        """Knit one or more s0titches.
        """
        self.work_stitches(StitchDetails("k"), count=count)
        return self

    def ktbl(self, count : int = 1):
        """Knit one or more stitches through the back loop.
        """
        self.work_stitches(StitchDetails("ktbl"), count)
        return self

    def p(self, count : int = 1):
        """Purl one or more stitches.
        """
        self.work_stitches(StitchDetails("p"), count)
        return self

    # Decreases
    def k2tog(self):
        """Knit two together.
        """
        self.work_stitches(StitchDetails("k2tog", parent_count=2))
        return self

    def p2tog(self):
        """Knit two together.
        """
        self.work_stitches(StitchDetails("p2tog", parent_count=2))
        return self

    def ssk(self):
        """Slip, slip, knit decrease"""
        self.work_stitches(StitchDetails("ssk", parent_count=2))
        return self

    # Increases
    def yo(self, count : int = 1):
        """Yarnover
        """
        if count == 0:
            return self
        self.work_stitches(StitchDetails(code="yo", parent_count=0), count)
        return self

    def m(self, count : int = 1):
        """
        An unspecified and unparented increase.
        """
        self.work_stitches(StitchDetails("m", parent_count=0))
        return self

    def m1l(self):
        """Make one left.
        """
        self.work_stitches(StitchDetails("m1l", parent_count=0))
        return self

    def m1r(self):
        """Make one right."""
        self.work_stitches(StitchDetails("m1r", parent_count=0))
        return self

    def kfb(self):
        """Knit front and back.
        """
        self.ensure_row_scope()
        self.scopes.append(NamedInstructionGroup("kfb"))
        self.work_stitches(StitchDetails("kfb1", slip_from_left_needle=False))
        self.work_stitches(StitchDetails("kfb2"))
        self.scopes.pop()
        return self

    # Miscellaneous
    def w(self):
        """Wrap a stitch
        """
        # w is yo + s, but worked as a single stitch
        self.work_stitches(StitchDetails("w"))
        #self.ensure_row_scope()
        #group = NamedInstructionGroup("w")
        #self.push_scope(group)
        #self.yo().s()
        #self.pop_scope(group)
        return self

    # Bindoff
    def bo(self, count : int = 1):
        """Bind off.
        """
        self.work_stitches(StitchDetails("bo", add_to_right_needle=False), count=count)
        return self
    
    def pte(self,
            stitches_left : int = 0,
            slip_markers : bool = True,
            log_count : bool = True):
        """Purl to end
        If knitting in the round, this purls all stitches currently on the needle.

        :params stitches_left: The number of stitches to leave on the
            right needle before the end.
        :param slip_markers: If true, slip any markers encountered. If false,
            markers will trigger an error.
        :param log_count: Whether to log the total count knitted.
        """
        self.stitch_to_end(StitchDetails("p"), stitches_left, marker_action="slip" if slip_markers else "fail")
        return self
        
    def kte(self,
            stitches_left : int = 0,
            slip_markers : bool = True,
            log_count : bool = True,
            ):
        """Knit to end.

        If knitting in the round, this knits all stitches currently on the needle

        :params stitches_left: The number of stitches to leave on the
            right needle before the end.
        :param slip_markers: If true, slip any markers encountered. If false,
            markers will trigger an error.
        :param log_count: Whether to log the total count knitted.
        """
        self.stitch_to_end(StitchDetails("k"), stitches_left, marker_action="slip" if slip_markers else "fail")
        return self
        
    def ktm(self, stitches_left : int = 0, log_count : bool = True):
        """
        Knit to the next marker.

        :params stitches_left: The number of stitches to leave on the
            right needle before the marker.
        :param log_count: Whether to log the total count knitted.
        """
        self.stitch_to_marker(StitchDetails("k"), stitches_left, log_count)
        return self

    def ptm(self, stitches_left : int = 0, log_count : bool = True):
        """
        Purl to the next marker.

        :params stitches_left: The number of stitches to leave on the
            right needle before the marker.
        """
        self.stitch_to_marker(StitchDetails("p"), stitches_left, log_count)
        return self

    def botm(self, stitches_left : int = 0, log_count : bool = True):
        """
        Bind off to the next marker.

        :params stitches_left: The number of stitches to leave on the
            right needle before the marker.
        """
        self.stitch_to_marker(
            StitchDetails("bo", add_to_right_needle=False),
            stitches_left,
            log_count
        )
        return self



    def bind_off_to_end(self):
        self.stitch_to_end(StitchDetails(code="bo", add_to_right_needle=False), marker_action="remove")
        return self

    # Markers
    def pm(self, name=""):
        """Place a marker.

        :param name: If specified, this will be the name of the marker.
        """
        logger.debug(f"Placing marker {name}")
        marker = Marker(name)
        self.right.push(marker)
        self.ensure_row_scope().items.append(MarkerAction("pm", name))
        return self

    def sm(self, name=""):
        "Slip a marker from the left to the right needle"
        (marker,) = self.left.pop()
        if not isinstance(marker, Marker):
            raise KnittingException(f"Next item is not a marker")
        if name and marker != name:
            raise KnittingException(f"Expecting marker named {name}, got {marker}")
        logger.debug(f"Slipping marker {marker}")
        self.right.push(marker)
        self.ensure_row_scope().add_item(MarkerAction("sm", name))
        return self
    
    def rm(self, name=""):
        """Remove a marker. If ``name`` is specified, the marker must have that name.

        :param name: The name of the marker. If this is left out, the maker will be removed, regardless of name.
        :raise KnittingException: If the next item on the left needle is not a marker
                                  or the name of the marker doesn't match.
        """
        logger.debug(f"Removing marker {name}")
        (marker,) = self.left.pop()
        if not isinstance(marker, Marker):
            raise KnittingException(f"Next item is not a marker")
        if name and marker != name:
            raise KnittingException(f"Expecting marker named {name}, got {marker}")
        self.ensure_row_scope().items.append(MarkerAction("rm", name))
        return self

    def turn(self, short_row=False):
        """
        Swap the left and right needles.

        Unless `short_row` is set to True, turning with stitches left on the left needle
        will raise an exception.
        """
        self.ensure_row_scope().items.append(Instruction("turn"))
        if short_row or self.left.is_empty:
            self.right, self.left = self.left, self.right
            if not short_row:
                self.end_of_row()
            logger.debug(f"Turn")
        else:
            raise KnittingException(f"Short row")        
        return self

    def end_of_row(self, tag : str = ""):
        """End the current row and output logging.

        :param tag: Tag to apply to the row.
        """
        if tag:
            self.tag(tag)
        increases = self.left.stitch_count - self.initial_stitch_count
        row = self.scope
        assert isinstance(row, Row)
        row.increase_count = increases
        row.log_increase_count = increases != 0 and self.left.stitch_count != 0
        self.scopes.pop()
        logger.debug(f"End of row")
        self.stitch_number = 0
        self.row_number += 1

        if self.auto_break > 0:
            if self.row_number % self.auto_break == 0:
                self.add_break()

        self.initial_stitch_count = self.left.stitch_count
        return self

    def join(self):
        """
        Join the work.
        
        This replaces the current left needle with a "reverse view" of the right needle (to simulate a circular needle),
        and requires the current left needle to be empty.
        """
        if self.left.items:
            raise KnittingException(f"Unable to join, {len(self.left.items)} left on left needle")
        self.left = BackNeedle(self.right)
        row = self.scope
        assert isinstance(row, Row)
        row.items.append(Instruction("join"))
        return self

    def repeat(self, count : int):
        """Yields numbers from 0 to ``count``.
        
        After the first iteration, output is suppressed. On completion,
        a summary of the additional repetitions is added to the log.

        :param count: The number of repetitions, including the first one.
        """
        self.ensure_section_scope()
        group = RowGroup()
        self.push_scope(group)
        yield 0
        self.scopes.pop()
      
        repeats = RepeatedRowGroup(group)
        self.push_scope(repeats)
        for i in range(1, count):
            yield i
        self.scopes.pop()

            
    def summarize(self):
        """Summarizes the stitches on the left needle.
        
        This method returns a list of numbers that represent the sections of stitches between
        the current location, the end, and any markers in between.
        The numbers sum to the number of stitches on the left needle and the length of the list is
        one more than the number of markers (i.e. one number if there are zero markers, two if
        there is one marker).
                
        .. attention::

            If knitting in the round with a single BOR marker that has just been slipped, a list of *two*
            items is still returned: The total number of stitches on the needle and zero, which is the number of stitches 
            from the BOR marker to the current location.

        :return: A list of integers one longer than the number of markers (i.e. one number if there are zero markers)
        """
        return self.left.summarize()

    def tag(self, tag : str):
        self.scope.tag = tag
        return self

    def end_of_pattern(self):
        while self.scopes:
            self.pop_scope()
        return self

    @contextmanager
    def section(self, title):
        self.end_of_section()
        section = Section(title)
        self.push_scope(section)
        yield
        self.pop_scope(section)

    def end_of_section(self):
        """Discards scopes until the current scope is the pattern
        
        :meta private:
        """
        while not isinstance(self.scope, Pattern):
            self.scopes.pop()
        return self

    def comment(self, content : Any) -> Self:
        """Add a comment to the current row, section or pattern
        """
        text = str(content)
        if isinstance(self.scope, Row):
            self.pop_scope()
            assert type(self.scope) == Section
            self.scope.items.append(Comment(text))
            self.push_scope(Row(self.row_number, continued=True))
            return self
        elif isinstance(self.scope, (Pattern, Section)):
            self.scope.items.append(Comment(text))
            return self
        else:
            raise PatternException("No commentable scope found")

    def add_break(self):
        self.scope.items.append(Break())
    
    # Do/repeat
    def do_and_repeat(self,
           f : Callable[[Self], Any],
           repeat_type : Literal["once", "count", "end", "marker"],
           repeat_count : Optional[int] = None,
           stitches_left : int = 0,
           ):
        """
        Execute a callable once and return a :class:`Repeater` that allows you to repeat it.

        :param f: The callable to repeat.
        :return: A :class:`Repeater` object.
        """
        # If the terminating condition of the repeat is already met, do nothing
        if repeat_type == "marker" and self.at_marker(stitches_left=stitches_left):
            return self
        if repeat_type == "end" and self.at_end(stitches_left=stitches_left):
            return self

        # Do
        stitch_group = InstructionGroup()
        self.ensure_row_scope()
        self.push_scope(stitch_group)
        f(self)
        self.pop_scope(stitch_group)

        # Repeat
        self.push_scope(RepeatedInstructionGroup(stitch_group))
        count = 0
        if repeat_type == "once":
            pass
        elif repeat_type == "count":
            assert repeat_count is not None
            count = repeat_count
            for _ in range(repeat_count):
                f(self)
        elif repeat_type == "marker":
            while not self.at_marker(stitches_left=stitches_left):
                f(self)
                count += 1
        elif repeat_type == "end":
            while not self.at_end(stitches_left):
                f(self)
                count += 1
        self.scope.repeat_type = repeat_type
        self.scope.repeat_count = count
        self.scope.stitches_left = stitches_left
        self.scopes.pop()
        return self

    def do(self, f : Callable[[Self], Any]):
        return Repeater(self, f) # type: ignore

    def save(self, path : str, format="markdown"):
        """Saves the pattern at the specified path.
        
        the format is determined from the extension of the filename or the `format` parameter"""
        if path.endswith(".md") or format == "markdown":
            self.save_markdown(path)

    def save_markdown(self, path):
        """Saves the pattern as markdown at the specified path"""
        with open(path, mode="w", encoding="utf8") as f:
            MarkdownFormatter(f).format_pattern(self.pattern)

    def stitch_group(self, name : Optional[str] = None):
        def get_wrapper(f):
            @wraps(f)
            def wrapper():
                self.push_scope(NamedInstructionGroup(name or f.__name__.replace("_", " ")))
                return_value = f()
                self.scopes.pop()
                return return_value
            return wrapper
        return get_wrapper

    @property
    def stitches(self):
        codes = list()
        for item in self.left:
            if isinstance(item, Stitch):
                codes.append(self.g.nodes[item]["details"].code)
            if isinstance(item, Marker):
                codes.append("M")
        return "-".join(codes)
                       

class Repeater:
    def __init__(self, pb : Builder, repeatable : Callable[[Builder], Any]):
        self.pb = pb
        self.repeatable = repeatable

    def once(self):
        return self.pb.do_and_repeat(self.repeatable, "once")

    def repeat(self, count : int) -> Builder:
        return self.pb.do_and_repeat(self.repeatable, "count", repeat_count=count)

    def to_end(self, stitches_left : int = 0) -> Builder:
        """Repeat the callable to end, summarising output.

        :param stitches_left: The number of stitches before the end to stop.
        :param log_count: If True, the number for repetitions will be logged.
        """
        return self.pb.do_and_repeat(self.repeatable, "end", stitches_left=stitches_left)

    def to_marker(self, stitches_left : int = 0) -> Builder:
        return self.pb.do_and_repeat(self.repeatable, "marker", stitches_left=stitches_left)

def section(name):
    def get_wrapper(f):
        @wraps(f)
        def wrapper(self : Builder):
            self.push_scope(Section(name))
            return_value = f(self)
            self.scopes.pop()
            return return_value
        return wrapper
    return get_wrapper
