# *************************************************************************
#
#  Copyright (c) 2026 - Datatailr Inc.
#  All Rights Reserved.
#
#  This file is part of Datatailr and subject to the terms and conditions
#  defined in 'LICENSE.txt'. Unauthorized copying and/or distribution
#  of this file, in parts or full, via any medium is strictly prohibited.
# *************************************************************************

import json
import os
import re
import sys
import subprocess
from typing import Optional


class Image:
    """
    Represents a container image for a job.

    The image is defined based on Python dependencies and two build scripts
    expressed as Dockerfile commands.

    Args:
        python_version (str): Python version string or "auto".
        python_requirements (str | list[str]): Requirements spec, file path, list, or "auto".
        build_script_pre (str): Pre-build Dockerfile commands or file path.
        build_script_post (str): Post-build Dockerfile commands or file path.
        branch_name (str | None): Git branch name for the repo, if applicable.
        commit_hash (str | None): Git commit hash for the repo, if applicable.
        path_to_repo (str | None): Path to the repository root, if applicable.
        path_to_module (str | None): Path to the Python module, if applicable.

    Attributes:
        python_version (str): Resolved Python version string.
        python_requirements (str): Requirements string (newline-delimited).
        build_script_pre (str): Pre-build Dockerfile commands.
        build_script_post (str): Post-build Dockerfile commands.
        branch_name (str | None): Git branch name.
        commit_hash (str | None): Git commit hash.
        path_to_repo (str | None): Path to the repository root.
        path_to_module (str | None): Path to the Python module.
        build_stdout (str | None): Stdout captured from the last build, if any.
        build_stderr (str | None): Stderr captured from the last build, if any.
        name (str | None): Built image name, if any.
        last_build_succeeded (bool | None): Whether the last build succeeded.
        bundle_name (str | None): Bundle name, if any.
        registry (str | None): Registry URL or name, if any.
        run_after_build (bool | None): Whether to run after build.
        tag (str | None): Image tag, if any.
    """

    def __init__(
        self,
        python_version: str = "auto",
        python_requirements: str | list[str] = "",
        build_script_pre: str = "",
        build_script_post: str = "",
        branch_name: Optional[str] = None,
        commit_hash: Optional[str] = None,
        path_to_repo: Optional[str] = None,
        path_to_module: Optional[str] = None,
    ):
        """Initialize an Image configuration.

        Args:
            python_version: Python version for the container (e.g. ``"3.12"``).
                Use ``"auto"`` to match the current interpreter version.
            python_requirements: Python dependencies. Accepts a pip-freeze
                string, a list of package specifiers, a path to a
                ``requirements.txt`` file, or ``"auto"`` to run ``pip freeze``.
            build_script_pre: Dockerfile commands to execute **before**
                installing Python requirements.  Can be a string or a file path.
            build_script_post: Dockerfile commands to execute **after**
                installing Python requirements.  Can be a string or a file path.
            branch_name: Git branch name (populated automatically during build).
            commit_hash: Git commit hash (populated automatically during build).
            path_to_repo: Absolute path to the source repository root.
            path_to_module: Relative path from the repository root to the
                Python module containing the entrypoint.
        """
        self.python_version = python_version
        self.python_requirements = python_requirements
        self.build_script_pre = build_script_pre
        self.build_script_post = build_script_post
        self.branch_name = branch_name
        self.commit_hash = commit_hash
        self.path_to_repo = path_to_repo
        self.path_to_module = path_to_module

        # Additional attributes, available after the image was built
        self.build_stdout: Optional[str] = None
        self.build_stderr: Optional[str] = None
        self.name: Optional[str] = None
        self.last_build_succeeded: Optional[bool] = None
        self.bundle_name: Optional[str] = None
        self.registry: Optional[str] = None
        self.run_after_build: Optional[bool] = False
        self.tag: Optional[str] = None
        self.id: Optional[int] = None

    @property
    def python_version(self):
        return self._python_version

    @python_version.setter
    def python_version(self, value: str | None):
        if value is None or value == "":
            value = "3.13"
        if not isinstance(value, str):
            raise TypeError("python_version must be a string.")
        if value.lower() == "auto":
            value = f"{sys.version_info.major}.{sys.version_info.minor}"
        if not re.match(r"^3\.1[0-4](\.\d+)?$", value):
            raise ValueError(
                f"Invalid python_version: {value}. Must be either 3.10, 3.11, 3.12, 3.13, or 3.14. It may also be a patch version like 3.13.1."
            )
        self._python_version = value

    @property
    def python_requirements(self):
        return self._python_requirements

    @python_requirements.setter
    def python_requirements(self, value: str | list[str]):
        if isinstance(value, str):
            if value == "auto":
                # Automatically deduce the requirements from the current environment by running pip freeze
                try:
                    value = subprocess.run(
                        ["pip", "freeze"],
                        stdout=subprocess.PIPE,
                        stderr=subprocess.PIPE,
                        text=True,
                        check=True,
                        timeout=60,
                    ).stdout
                except Exception as e:
                    raise RuntimeError(
                        "Failed to auto-deduce python_requirements. Ensure that pip is installed in the current environment."
                    )
            elif os.path.isfile(value):
                with open(value, "r") as f:
                    value = f.read()
            elif "," in value:
                value = value.replace(",", "\n")
        elif isinstance(value, list):
            value = "\n".join(value)
        if not isinstance(value, str):
            raise TypeError(
                "python_requirements must be a list of strings, a file path to a requirements file or 'auto'."
            )
        self._python_requirements = value

    @property
    def build_script_pre(self):
        return self._build_script_pre

    @build_script_pre.setter
    def build_script_pre(self, value: str):
        if not isinstance(value, str):
            raise TypeError(
                "build_script_pre must be a string or a file path to a script file."
            )
        if os.path.isfile(value):
            with open(value, "r") as f:
                value = f.read()
        self._build_script_pre = value

    @property
    def build_script_post(self):
        return self._build_script_post

    @build_script_post.setter
    def build_script_post(self, value: str):
        if not isinstance(value, str):
            raise TypeError(
                "build_script_post must be a string or a file path to a script file."
            )
        if os.path.isfile(value):
            with open(value, "r") as f:
                value = f.read()
        self._build_script_post = value

    def update(self, **kwargs):
        """Update one or more Image attributes.

        Args:
            **kwargs: Attribute names and their new values.

        Raises:
            TypeError: If a string-only attribute receives a non-string value.
            AttributeError: If an attribute name does not exist on the Image.
        """
        for key, value in kwargs.items():
            if (
                key in ["branch_name", "commit_hash", "path_to_repo", "path_to_module"]
                and value is not None
                and not isinstance(value, str)
            ):
                raise TypeError(f"{key} must be a string or None.")
            if not hasattr(self, key):
                raise AttributeError(
                    f"'{self.__class__.__name__}' object has no attribute '{key}'"
                )
            setattr(self, key, value)

    def to_dict(self):
        """
        Convert the Image instance to a dictionary representation.
        """
        return {
            "python_version": self.python_version,
            "python_requirements": self.python_requirements,
            "build_script_pre": self.build_script_pre,
            "build_script_post": self.build_script_post,
            "branch_name": self.branch_name,
            "commit_hash": self.commit_hash,
            "path_to_repo": self.path_to_repo,
            "path_to_module": self.path_to_module,
        }

    def to_json(self):
        """
        Convert the Image instance to a JSON string representation.
        """
        return json.dumps(self.to_dict(), indent=4)

    def from_dict(self, data: dict) -> "Image":
        """
        Create an Image instance from a dictionary representation.
        """
        self.id = data.pop("image_id", None)
        self.name = data.pop("image_name", None)
        for key, value in data.items():
            if not hasattr(self, key):
                raise AttributeError(
                    f"'{self.__class__.__name__}' object has no attribute '{key}'"
                )
            setattr(self, key, value)

        return self

    def __eq__(self, other):
        if not isinstance(other, Image):
            return NotImplemented
        return (
            self.python_version == other.python_version
            and self.python_requirements == other.python_requirements
            and self.build_script_pre == other.build_script_pre
            and self.build_script_post == other.build_script_post
            and self.branch_name == other.branch_name
            and self.commit_hash == other.commit_hash
            and self.path_to_repo == other.path_to_repo
            and self.path_to_module == other.path_to_module
        )
