from contextlib import contextmanager
import math
from networkx import DiGraph, reverse_view
from typing import Callable, List, Optional, Self, Sequence, Set

from yarnover.exceptions import KnittingException, PatternException
from yarnover.needle import Needle, BackNeedle, Stitch, Marker

import logging

logger = logging.getLogger(__name__)

def format_marker(action, name):
    if name:
        return f"{action}<{name}>"
    else:
        return action
    
def format_stitches(code, count):
    if count == 1:
        return code
    else:
        return f"{code}{count}"

def format_do_this_to_that(code, stitches_left, count, log_count):
    if stitches_left == 0:
        if log_count:
            return f"{code}({count})"
        else:
            return code
    else:
        if log_count:
            return f"{code}-{stitches_left}({count})"
        else:
            return f"{code}-{stitches_left}"

def format_row_block_reference(from_row, to_row):
    if from_row == to_row-1:
        return f"row {from_row}"
    else:
        return f"rows {from_row}-{to_row-1}"

def format_increase_count(inc):
    if inc < -1:
        return f"{-inc} sts decreased"
    if inc == -1:
        return f"{-inc} st decreased"
    if inc == 0:
        return f"no sts increased"
    if inc == 1:
        return f"{inc} st increased"
    if inc > 1:
        return f"{inc} sts increased"

class Repeater:
    """Repeats a callable with summarized logging.
    """
    def __init__(self, p : "Pattern", f : Callable[["Pattern"], "Pattern"]):
        # TODO: Kan afhængighed til Pattern fjernes?
        """
        :param p: The pattern whose logging is replaced.
        :param f: The callable to be called. Executing the callable should affect the state of ``p``.
        """
        self.p = p
        self.f = f

    def once(self):
        return self.f(self.p)

    def repeat(self, count : int):
        log_length = len(self.p.row_log)
        for _ in range(count):
            self.f(self.p)
        self.p.row_log = self.p.row_log[:log_length]
        self.p.row_log.append(f"repeat {count} times")
        return self.p

    def to_end(self, stitches_left : int = 0, log_count : bool = True):
        """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.
        """
        log_length = len(self.p.row_log)
        count = 0
        while not self.p.at_end(stitches_left):
            self.f(self.p)
            count += 1
        self.p.row_log = self.p.row_log[:log_length]
        if count == 0:
            return self.p
        if stitches_left:
            if log_count:
                if count == 1:
                    self.p.row_log.append(f"repeat once to {stitches_left} before end")
                if count > 1:
                    self.p.row_log.append(f"repeat to {stitches_left} before end ({count} times)")
            else:
                self.p.row_log.append(f"repeat to {stitches_left} before end")
        else:
            self.p.row_log.append(f"repeat to end")
        return self.p

    def to_marker(self, marker_name : str = "", stitches_left : int = 0, log_count : bool = True):
        log_length = len(self.p.row_log)
        count = 0
        while not self.p.at_marker(marker_name, stitches_left):
            self.f(self.p)
            count += 1
        self.p.row_log = self.p.row_log[:log_length]
        if stitches_left:
            if log_count:
                self.p.row_log.append(f"repeat to {stitches_left} before marker ({count} times)")
            else:
                self.p.row_log.append(f"repeat to {stitches_left} before marker")
        else:
            if log_count:
                self.p.row_log.append(f"repeat to marker ({count} times)")
            else:
                self.p.row_log.append(f"repeat to marker")
        return self.p

class Pattern:
    def __init__(self):
        self.left = Needle()
        self.right = Needle()
        self.g : DiGraph = DiGraph()
        self.g_rev = self.g.reverse(copy=False)

        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.row_log = list()

        self.quiet = False

    def log_paragraph(self, text : str):
        if self.row_log:
            raise PatternException("Row in progress, can't log paragraph") # TODO: Skal vi det?
        print(text)

    def work_stitch(self,
                    code : str,
                    parent_count : int = 1,
                    slip_from_left_needle : bool = True,
                    add_to_right_needle : bool = True):
        """
        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

        column_number = self.get_column_number(stitch, parent_count)

        if column_number is None:
            print("!")

        self.g.add_node(
            stitch,
            name=code,
            row_number=self.row_number,
            column_number=column_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 parent_count > 0:
            for i in range(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(parent_stitch, stitch, relation="child")

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

    def get_column_number(self, stitch, parent_count):
        # If this is the first row, number sequentially
        if self.row_number == 0:
            return stitch

        # If this is an unparented increase, bump and assign
        elif parent_count == 0:
            if self.left.is_empty:
                if self.last_stitch_worked:
                    return self.g.nodes[self.last_stitch_worked]["column_number"]+1
                else:
                    raise KnittingException("No next stitch, no previous stitch")
            else:
                return self.g.nodes[self.left.get_next_stitch()]["column_number"]
            self.bump_stitches(column_number)
        
        # If the first parent stitch has other children, bump and insert
        elif parent_count > 0:
            parent_stitch = self.left.peek()
            if not isinstance(parent_stitch, Stitch):
                raise KnittingException(f"Trying to knit non-stitch item: {parent_stitch}")
            has_sibling = False
            for related_stitch in self.g.successors(parent_stitch):
                if self.g[parent_stitch][related_stitch]["relation"] == "child":
                    has_sibling = True
                    return self.g.nodes[parent_stitch]["column_number"]
                    self.bump_stitches(column_number)
                    break
            if not has_sibling:
                return self.g.nodes[parent_stitch]["column_number"]
            

    def bump_stitches(self, from_column : int):
        nodes_to_bump = list()
        for node, column_number in self.g.nodes(data="column_number"):
            if column_number >= from_column: # type: ignore
                nodes_to_bump.append(node)
        for node in nodes_to_bump:
            self.g.nodes[node]["column_number"] += 1

    def repeat_stitch(self,
                      code : str,
                      parent_count : int = 1,
                      slip_from_left_needle : bool = True,
                      add_to_right_needle : bool = True,
                      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}: {code}*{count}")
        for _ in range(count):
            self.work_stitch(
                code,
                parent_count,
                slip_from_left_needle,
                add_to_right_needle
            )

    def stitch_to_end(self,
                      code : str,
                      parent_count : int = 1,
                      slip_from_left_needle : bool = True,
                      add_to_right_needle : bool = True,
                      stitches_left : int = 0,
                      marker_action : str = "fail") -> int:
        """
        Repeat a 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.
        :param marker_action: What to do when a marker is encountered - "fail", "slip" or "remove".
        :meta private:
        """
        total_count = 0
        log_length = len(self.row_log)
        blocks = self.summarize()
        while len(blocks) > 1:
            block = blocks.pop(0)
            total_count += block
            self.repeat_stitch(
                code,
                parent_count,
                slip_from_left_needle,
                add_to_right_needle,
                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.repeat_stitch(
            code,
            parent_count,
            slip_from_left_needle,
            add_to_right_needle,
            block)
        total_count += block-stitches_left
        self.row_log = self.row_log[:log_length]
        return total_count

    def stitch_to_marker(self,
                         code : str,
                         parent_count : int = 1,
                         slip_from_left_needle : bool = True,
                         add_to_right_needle : bool = True,
                         stitches_left : int = 0):
        """
        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.
        :meta private:
        """
        log_length = len(self.row_log)
        distance = self.left.distance_to_marker()
        if distance < stitches_left:
            raise KnittingException(f"Less than {stitches_left} stitches left to marker")
        count = distance-stitches_left
        self.repeat_stitch(
            code,
            parent_count,
            slip_from_left_needle,
            add_to_right_needle,
            count)
        self.row_log = self.row_log[:log_length]
        return count

    # Markers
    def at_marker(self, marker_name : str = "", 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)
        if isinstance(next_item, Marker):
            if marker_name:
                return next_item == marker_name
            else:
                return True

    # 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.repeat_stitch(code="co", parent_count=0, count=count)
        self.row_log.append(format_stitches("co", 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.
        """
        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.row_log.append(format_stitches("s", count))
        return self

    def k(self, count : int = 1):
        """Knit one or more s0titches.
        """
        self.repeat_stitch(code="k", count=count)
        self.row_log.append(format_stitches("k", count))
        return self

    def ktbl(self, count : int = 1):
        """Knit one or more stitches through the back loop.
        """
        self.repeat_stitch(code="ktbl", count=count)
        self.row_log.append(format_stitches("ktbl", count))
        return self

    def p(self, count : int = 1):
        """Purl one or more stitches.
        """
        self.repeat_stitch("p", count=count)
        self.row_log.append(format_stitches("p", count))
        return self

    # Decreases
    def k2tog(self):
        """Knit two together.
        """
        self.work_stitch("k2tog", parent_count=2)
        self.row_log.append(f"k2tog")
        return self
    
    def ssk(self):
        """Slip, slip, knit decrease"""
        self.work_stitch("ssk", parent_count=2)
        self.row_log.append(f"ssk")
        return self

    # Increases
    def yo(self, count : int = 1):
        """Yarnover
        """
        self.repeat_stitch(code="yo", parent_count=0, count=count)
        self.row_log.append(format_stitches("yo", count))
        return self

    def m(self, count : int = 1):
        """
        An unspecified and unparented increase.
        """
        self.work_stitch("m", parent_count=0)
        self.row_log.append(format_stitches("m", count))
        return self

    def m1l(self):
        """Make one left.
        """
        self.work_stitch("m1l", parent_count=0)
        self.row_log.append(f"m1l")
        return self

    def m1r(self):
        """Make one right."""
        self.work_stitch("m1r", parent_count=0)
        self.row_log.append(f"m1r")
        return self

    def kfb(self):
        """Knit front and back.
        """
        self.work_stitch("kfb1", slip_from_left_needle=False)
        self.work_stitch("kfb2")
        self.row_log.append(f"kfb")
        return self

    # Bindoff
    def bo(self, count : int = 1):
        """Bind off.
        """
        self.repeat_stitch(code="bo", add_to_right_needle=False, count=count)
        self.row_log.append(format_stitches("bo", count))
        return self
    

    def pte(self,
            stitches_left : int = 0,
            slip_markers : bool = False,
            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.
        """
        count = self.stitch_to_end(code="p",
                                   stitches_left=stitches_left,
                                   marker_action="slip" if slip_markers else "none",
                                   )
        self.row_log.append(format_do_this_to_that("pte", stitches_left, count, log_count))
        return self

    def kte(self,
            stitches_left : int = 0,
            slip_markers : bool = False,
            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.
        """
        count = self.stitch_to_end(code="k",
                                   stitches_left=stitches_left,
                                   marker_action="slip" if slip_markers else "none",
                                   )
        self.row_log.append(format_do_this_to_that("kte", stitches_left, count, log_count))
        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.
        """
        count = self.stitch_to_marker("k", stitches_left=stitches_left)
        self.row_log.append(format_do_this_to_that("ktm", stitches_left, count, log_count))
        return self

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

        :params stitches_left: The number of stitches to leave on the
            right needle before the marker.
        """
        count = self.stitch_to_marker("p", stitches_left=stitches_left)
        self.row_log.append(format_do_this_to_that("ptm", stitches_left, count, log_count))
        return self

    def bind_off_to_end(self, stitches_left : int = 0):
        self.stitch_to_end(code="bo",
                           stitches_left=stitches_left,
                           marker_action="remove"
                           )
        self.row_log.append("bind off to end")
        return self

    # Markers
    def pm(self, name : Optional[str] = None):
        """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.row_log.append(format_marker("pm", name))
        return self

    def sm(self, name : Optional[str] = None):
        "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.row_log.append(format_marker("sm", name))
        return self
    
    def rm(self, name : Optional[str] = None):
        """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.row_log.append(format_marker("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.
        """
        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, description : str = ""):
        """End the current row and output logging.

        :param description: A descriptive text to replace the generated log of the row.
        """
        increases = self.left.stitch_count - self.initial_stitch_count
        description = description or ' '.join(self.row_log)
        if not self.quiet:
            if increases == 0 or self.left.stitch_count == 0:
                print(f"- Row {self.row_number}: {description}")
            else:
                print(f"- Row {self.row_number}: {description} ({format_increase_count(increases)})")
        self.row_log.clear()
        logger.debug(f"End of row")
        self.stitch_number = 0
        self.row_number += 1
        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)
        return self

    def move_stitches(self, count : int):
        "Move stitches to another needle."
        logger.debug(f"Moving {count} stitches from left needle")
        self.row_log.append(f"remove {count} stitches")
        return self.left.pop(count)

    def do(self, f : Callable[[Self], Self], description : Optional[str] = None):
        """
        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 that 
        """
        log_checkpoint = len(self.row_log)
        f(self)
        self.row_log, block_log = self.row_log[:log_checkpoint], self.row_log[log_checkpoint:]
        self.row_log.append(f"[{description or ' '.join(block_log)}]")

        return Repeater(self, f)

    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.
        """
        start_row = self.row_number
        yield 0
        end_row = self.row_number
        with self.quietly:
            for i in range(1, count):
                yield i
                
        print(f"- Rows {format_row_block_reference(end_row, self.row_number)}: Repeat {format_row_block_reference(start_row, end_row)} {count-1} more times")

    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)
        :meta private:
        """
        return self.left.summarize()


    @property
    @contextmanager
    def quietly(self):
            log_checkpoint = len(self.row_log)
            yield None
            self.row_log = self.row_log[:log_checkpoint]

    def resume(self, stitches):
        if self.left.is_empty:
            self.left.items.extend(stitches)
