"""This module allows submissions of proposals and blocks."""

import dataclasses
from datetime import datetime, timedelta
from enum import Enum
from pathlib import Path
from typing import IO, Any, BinaryIO, Union
from zipfile import ZipFile, is_zipfile

import defusedxml.ElementTree as ET

from pyastrosalt.session import Session


class SubmissionStatus(str, Enum):
    """
    The submission status.

    There are three possible status values:

    1. Failed: The submission was terminated with an error.
    2. In progress: The submission is still ongoing.
    3. Successful: The submission has been completed successfully.
    """

    FAILED = "Failed"
    IN_PROGRESS = "In progress"
    SUCCESS = "Successful"


class SubmissionMessageType(str, Enum):
    """
    The type of submission message.

    A submission message can have one of three types:

    1. Info: A purely informational message, such as an indication that a finder chart
       is generated.
    2. Error: An error. This implies that the submission has failed.
    3. Warning: Something might need attention, but the submission proceeded despite the
       potential problem.
    """

    INFO = "Info"
    ERROR = "Error"
    WARNING = "Warning"


@dataclasses.dataclass
class SubmissionLogEntry:
    """
    A log entry generated by the server during the submission.

    Attributes:
        logged_at: The datetime when the log entry was generated.
        message_type: The type of log message.
        message: The log message.

    """

    logged_at: datetime
    message_type: SubmissionMessageType
    message: str


class Submission:
    """
    A proposal or block submission.

    An instance of this class must be created with the submission identifier which the
    server returned when the proposal or block was submitted. It can then be used to
    track the submission progress by using any of its properties. The values of the
    properties are calculated based on queries made to the server.

    The server queries for updating the status are throttled to one every ten seconds.
    It is therefore safe to frequently use the properties. If less than ten seconds have
    passed since the previous query, the value of a property is not updated when you
    use a property (again).

    Technically, whenever one of the properties is updated from a server query, all the
    other properties are updated as well.
    """

    MIN_TIME_BETWEEN_QUERIES = timedelta(seconds=10)

    def __init__(self, identifier: str):
        """Initializes the instance for a submission identifier.

        Args:
            identifier: A submission identifier.
        """
        self.identifier = identifier
        self.session = Session.get_instance()
        self._log_entries: list[SubmissionLogEntry] = []
        self._status = SubmissionStatus.IN_PROGRESS
        self._error: str | None = None
        self._proposal_code: str | None = None
        self._last_queried_at: datetime | None = None

    @property
    def log(self) -> list[SubmissionLogEntry]:
        """The full submission log up to the current time."""
        self._query()
        return self._log_entries

    @property
    def status(self) -> SubmissionStatus:
        """The current submission status."""
        self._query()
        return self._status

    @property
    def error(self) -> str | None:
        """The submission error, if there is one."""
        self._query()
        return self._error

    @property
    def proposal_code(self):
        """The proposal code, if there is one.

        If a new proposal is submitted, the proposal code will be None until the
        submission finishes successfully.

        In case of a block submission the proposal code is that of the proposal for
        which the blocks are submitted.
        """
        self._query()
        return self._proposal_code

    def _query(self):
        # Avoid repeated queries to the server
        if (
            self._last_queried_at
            and datetime.now() - self._last_queried_at
            < Submission.MIN_TIME_BETWEEN_QUERIES
        ):
            return

        # Query the server for the latest status.
        self._last_queried_at = datetime.now()
        response = self.session.get(
            f"/submissions/{self.identifier}/progress",
            params={"from-entry-number": len(self._log_entries) + 1},
        ).json()

        # Update the status and proposal code.
        self._status = SubmissionStatus(response["status"])
        self._proposal_code = response["proposal_code"]

        # Update the log entries.
        new_log_entries = [
            SubmissionLogEntry(
                logged_at=datetime.fromisoformat(entry["logged_at"]),
                message_type=SubmissionMessageType(entry["message_type"]),
                message=entry["message"],
            )
            for entry in response["log_entries"]
        ]
        self._log_entries += new_log_entries

        # If the submission has failed, we search for the (last, but there should only
        # be one) error message in the log entries and use oit as the error.
        if self._status == SubmissionStatus.FAILED:
            for log_entry in self._log_entries[::-1]:
                if log_entry.message_type == SubmissionMessageType.ERROR:
                    self._error = log_entry.message


def submit(
    file: Union[Path, str, BinaryIO], proposal_code: str | None = None
) -> Submission:
    """Submit a proposal file.

    The submitted file must be a zip file containing files in a format understood by the
    SALT API. It has to contain an XML file with the whole proposal, blocks or a single
    block, as well as the required attachments.

    A file path or a file-like object may be passed as the file. In case of a file-like
    object it must support the seek method.

    If you submit a new proposal, the proposal code must be None. Conversely, if you
    resubmit an existing proposal, the proposal code must be that of the proposal.

    The function returns a Submission object, which you can use to track the submission
    progress.

    Args:
        file: The zip file containing the submitted content.
        proposal_code: The proposal code or None if this is a new submission.

    Returns:
        A Submission object for tracking the submission progress.
    """
    if not _is_file_like(file):
        with open(file, "rb") as f:  # type:ignore
            return _submit(f, proposal_code)
    else:
        return _submit(file, proposal_code)  # type:ignore


def _submit(file: IO[Any], proposal_code: str | None) -> Submission:
    # Do some sanity checks on the submitted content.
    _check_submitted_content(file, proposal_code)

    # Submit the file.
    session = Session.get_instance()
    data = {"proposal_code": proposal_code} if proposal_code is not None else {}
    response = session.post(
        "/submissions/",
        data=data,
        files={"proposal": file},
    )
    return Submission(response.json()["submission_identifier"])


def _check_submitted_content(file: IO[Any], proposal_code: str | None) -> None:
    # Make sure a zip file is submitted.
    if not is_zipfile(file):
        raise ValueError("The submitted file must be a zip file.")
    file.seek(0)

    # Get the files in the zip file.
    with ZipFile(file, "r") as z:
        filenames = z.namelist()
        file.seek(0)

        # Check that exactly one proposal or block file is present.
        required_filenames = [
            f for f in filenames if f in ("Proposal.xml", "Blocks.xml", "Block.xml")
        ]
        if len(required_filenames) == 0:
            raise ValueError(
                "The submitted zip file must contain a file Proposal.xml, Blocks.xml or Block.xml."
            )
        if len(required_filenames) > 1:
            raise ValueError(
                "The submitted zip file must contain exactly one of Proposal.xml, Blocks.xml or Block.xml."
            )

        # Check that a proposal code is present, if necessary.
        if "Blocks.xml" in required_filenames or "Block.xml" in required_filenames:
            if proposal_code is None:
                raise ValueError("A proposal code is required for a block submission.")

        # Check that the proposal code is consistent, if necessary.
        filename = required_filenames[0]
        if filename == "Proposal.xml":
            with z.open("Proposal.xml", "r") as p:
                tree = ET.parse(p)
                code = tree.getroot().attrib.get("code")
                if (
                    code
                    and not code.startswith("Unsubmitted")
                    and proposal_code != code
                ):
                    raise ValueError(
                        f"The proposal code argument ({proposal_code}) does not match "
                        f"the proposal code in the submitted Proposal.xml file "
                        f"({code})."
                    )
        file.seek(0)


def _is_file_like(obj: Any):
    # Check whether the given object is file-like.
    # Taken from Pandas (https://github.com/pandas-dev/pandas/blob/v2.2.3/pandas/core/dtypes/inference.py).
    if not (hasattr(obj, "read") or hasattr(obj, "write")):
        return False

    return bool(hasattr(obj, "__iter__"))
