"""Update a document's grade as well as the gradebook."""
from __future__ import annotations

import csv
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator, List

try:
    import docxrev
except TypeError as error:
    raise TypeError(
        "Cannot access the document. Save the document, close it,\n"
        "then re-open it and try again."
    ) from error
from natsort import natsorted

from gradedoc import shared
from gradedoc.configs import config
from gradedoc.toggle_active_review_pane import toggle_review_pane

__all__ = ["update_grade"]


def update_grade(document: docxrev.Document, gradebook_path: Path):
    """Update a document's grade as well as the gradebook.

    Parameters
    ----------
    document
        Document.
    gradebook_path
        Gradebook.
    """

    with document:
        grade = grade_document(document)
        update_document_scores(document, grade)
        update_gradebook(document, gradebook_path, grade)
        toggle_review_pane(document)


def grade_document(document: docxrev.Document) -> Grade:  # noqa: C901
    """Grade a document.

    Parameters
    ----------
    document
        Document.
    """

    # Search comments in reverse. This means that point deductions in any given section
    # will be encountered before we reach the header comment. This allows us to walk
    # through the comments once, and exit cleanly once we reach the first header
    # comment. We use `iter` and `next` on `comments` because they advance at a
    # different pace than the `header_comment_patterns` and `max_scores`.
    comments = iter(reversed(document.comments))
    header_comment_patterns = reversed(shared.HEADER_COMMENT_PATTERNS)
    max_scores = reversed(shared.MAX_SCORES)

    # Prepare the lists to be returned. They will be reversed at the end.
    scores: List[int] = []
    header_comments: List[docxrev.com.Comment] = []
    deductions = 0
    codes: List[str] = []

    # Get the scores for each section
    comment = safe_next(comments)
    for header_comment_pattern, max_score in zip(header_comment_patterns, max_scores):

        # Process point deductions while we don't recognize a header comment
        content_points_lost = 0
        while not header_comment_pattern.match(comment.text):

            # Increment the points lost in this section for matching comments
            if match := shared.CONTENT_POINTS_LOST_PATTERN.match(comment.text):
                content_points_lost += int(match["value"])

            # Increment total deductions for matching comments
            if match := shared.DEDUCTION_PATTERN.match(comment.text):
                deductions += int(match["value"])

            # Add text description to common deduction codes found in the document
            matches = [
                pattern.match(comment.text)
                for pattern in shared.COMMON_DEDUCTION_PATTERNS
            ]
            if any(matches):
                first_match = [match for match in matches if match][0]
                code = first_match["code"]
                if "D" not in code:  # Skip if this is a custom deduction
                    if not config.codes:
                        raise ValueError(
                            "No deduction codes found in configs."
                            "One config file may be overriding another,"
                            "make sure you don't have an empty 'codes' list."
                        )
                    try:
                        feedback = config.codes[code]
                    except KeyError as error:
                        raise KeyError(
                            f"No entry in configs for code {code}."
                        ) from error
                    first_line_of_comment = comment.text.split("\r", maxsplit=1)[0]
                    comment.update(first_line_of_comment + "\n\n" + feedback)
                    codes.append(code)

            # Try to get the next comment, raising an error if there are none left
            try:
                comment = safe_next(comments)
            except StopIteration as error:
                raise StopIteration(
                    "No comments in document, or template comments are out of order.\n"
                    f"Document: {document.name}"
                ) from error

        # We found a header comment. Store it and move on.
        header_comments.append(comment)
        comment = safe_next(comments)

        # Store the score for this section and skip the header comment
        scores.append(max_score - content_points_lost)

    header_comments.reverse()
    scores.reverse()
    return Grade(header_comments, scores, deductions, "; ".join(natsorted(codes)))  # type: ignore


def update_document_scores(document: docxrev.Document, grade: Grade):
    """Update document scores with the determined grade.

    Parameters
    ----------
    document
        Document.
    grade
        Grade.
    """

    # Update the summary comment scores
    summary_comment = document.comments[0]
    substitution = (
        rf"\g<content>{grade.content}\n"
        rf"\g<deductions>{grade.deductions}\n"
        rf"\g<grade>{grade.total}"
    )
    summary_comment.update(
        shared.SUMMARY_COMMENT_PATTERN.sub(substitution, summary_comment.text)
    )

    # Update the scores of the header comments
    for comment, pattern, score in zip(
        grade.header_comments, shared.HEADER_COMMENT_PATTERNS, grade.scores
    ):
        substitution = rf"\g<header>{score}"
        comment.update(pattern.sub(substitution, comment.text))


def update_gradebook(document: docxrev.Document, gradebook_path: Path, grade: Grade):
    """Write the grade for the paper being graded to a CSV file.

    Parameters
    ----------
    gradebook_path
        Gradebook.
    document
        Document.
    grade
        Grade.
    """

    # Prepare rows for the CSV
    header_row = [
        "Document",
        "Grade",
        "Total Content",
        "Total Deductions",
        "Deduction Codes",
        *[header.upper() for header in shared.FULL_HEADERS],
    ]

    new_row = [
        document.name,
        grade.total,
        grade.content,
        grade.deductions,
        grade.deduction_codes,
        *grade.scores,
    ]
    rows_to_write: List[Any] = []

    # Create the CSV and write the header if it doesn't exist
    if not os.path.exists(gradebook_path):
        with open(gradebook_path, "w", newline="") as file:
            writer = csv.writer(file)
            writer.writerow(header_row)

    # Build the rows of the CSV, overwriting or appending the current paper being graded
    with open(gradebook_path, "r", newline="") as file:

        update_existing_row = False
        reader = csv.reader(file)
        next(reader)  # skip the header

        # If the paper has already been graded, update that row, otherwise append it
        for row in reader:
            if row and new_row[0] == row[0]:
                rows_to_write.append(new_row)
                update_existing_row = True
            else:
                rows_to_write.append(row)
        if not update_existing_row:
            rows_to_write.append(new_row)

    # Write the updated file
    with open(gradebook_path, "w", newline="") as file:
        writer = csv.writer(file)
        writer.writerow(header_row)
        for row in rows_to_write:
            writer.writerow(row)


def safe_next(comments: Iterator[docxrev.com.Comment]) -> docxrev.com.Comment:
    """Safely get the next comment.

    Safely get the next comment, raising an error if all comments have been exhausted.

    Parameters
    ----------
    comments
        A comment iterator.
    document
        The document.

    Raises
    ------
    StopIteration
        If all comments are exhausted.
    """
    comment = None
    try:
        comment = next(comments)
    except StopIteration as error:
        if comment:
            message = (
                f"Exhausted comments in {comment.in_document.name} "
                "before finding all header comments."
            )
        else:
            message = "No comments in document."
        raise StopIteration(message) from error

    return comment


@dataclass
class Grade:
    """A grade."""

    header_comments: List[docxrev.com.Comment]
    """Header comments."""

    scores: List[int]
    """Scores."""

    deductions: int
    """Deductions."""

    @property
    def content(self) -> int:
        """Content score."""
        return sum(self.scores)

    @property
    def total(self) -> int:
        """Total score."""
        return self.content - self.deductions

    deduction_codes: str
    """ A semicolon delimited string of deduction codes."""
