import datetime as dt
import json
import logging
import os
import pathlib as pl
import subprocess
import sys
import threading
import time
import typing as tp

import pydantic
import pygments
from pygments.formatters import terminal as pterminal
from pygments.lexers import data as pdata

from cardonnay import ttypes

LOGGER = logging.getLogger(__name__)


class CustomEncoder(json.JSONEncoder):
    def default(self, o: tp.Any) -> tp.Any:  # noqa: ANN401
        if isinstance(o, pl.Path):
            return str(o)
        if isinstance(o, dt.datetime):
            return o.isoformat()
        return super().default(o)


def should_use_color() -> bool:
    if "NO_COLOR" in os.environ:
        return False
    if os.environ.get("CLICOLOR_FORCE") == "1":
        return True
    if sys.stdout.isatty():
        return os.environ.get("CLICOLOR", "1") != "0"
    return False


def write_json(out_file: pl.Path, content: dict) -> pl.Path:
    """Write dictionary content to JSON file."""
    with open(out_file, "w", encoding="utf-8") as out_fp:
        out_fp.write(json.dumps(content, indent=4))
    return out_file


def print_json_str(data: str) -> None:
    """Print JSON string to stdout in a pretty format."""
    if should_use_color():
        print(
            pygments.highlight(
                code=data, lexer=pdata.JsonLexer(), formatter=pterminal.TerminalFormatter()
            )
        )
    else:
        print(data)


def print_json(data: dict | list | pydantic.BaseModel) -> None:
    """Print JSON data to stdout in a pretty format."""
    if isinstance(data, pydantic.BaseModel):
        json_str = data.model_dump_json(indent=2)
    else:
        json_str = json.dumps(data, cls=CustomEncoder, indent=2)
    print_json_str(data=json_str)


def _stream(pipe: tp.TextIO, target: tp.TextIO) -> None:
    try:
        for line in pipe:
            target.write(line)
            target.flush()
    finally:
        pipe.close()


def run_command(
    command: str | list,
    workdir: ttypes.FileType = "",
    ignore_fail: bool = False,
    shell: bool = False,
) -> int:
    """Run command and stream output to stdout/stderr."""
    if isinstance(command, str):
        cmd = command if shell else command.split()
        cmd_str = command
    else:
        cmd = command
        cmd_str = " ".join(command)

    LOGGER.debug("Running `%s`", cmd_str)

    p = subprocess.Popen(
        cmd,
        cwd=workdir or None,
        shell=shell,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        text=True,
        bufsize=1,
    )

    threads = []

    if p.stdout:
        t_out = threading.Thread(target=_stream, args=(p.stdout, sys.stdout), daemon=True)
        threads.append(t_out)
        t_out.start()

    if p.stderr:
        t_err = threading.Thread(target=_stream, args=(p.stderr, sys.stderr), daemon=True)
        threads.append(t_err)
        t_err.start()

    for t_ in threads:
        t_.join()

    p.wait()

    if not ignore_fail and p.returncode != 0:
        msg = f"An error occurred while running `{cmd_str}`"
        raise RuntimeError(msg)

    return p.returncode


def run_detached_command(
    command: str | list[str],
    logfile: pl.Path,
    workdir: str | pl.Path = "",
) -> subprocess.Popen:
    """Start command in background, detached from the current process.

    Args:
        command: Command to run.
        workdir: Optional working directory.
        logfile: File where both stdout and stderr are redirected.
    """
    cmd = command.split() if isinstance(command, str) else command

    # Full detachment on Unix-like systems
    with open(logfile, "a") as logout:
        p = subprocess.Popen(
            cmd,
            cwd=pl.Path(workdir) if workdir else None,
            stdout=logout,
            stderr=subprocess.STDOUT,
            stdin=subprocess.DEVNULL,
            close_fds=True,
            start_new_session=True,
        )

    return p


def read_from_file(file: ttypes.FileType) -> str:
    """Read address stored in file."""
    with open(pl.Path(file), encoding="utf-8") as in_file:
        return in_file.read().strip()


def wait_for_file(
    file: ttypes.FileType,
    timeout: float,
    poll_interval: float = 0.2,
) -> bool:
    """Wait for a file to appear within a time limit.

    Args:
        file: Path to the target file.
        timeout: Time limit in seconds.
        poll_interval: Time between checks (default 0.2s).

    Returns:
        True if file appeared in time, False otherwise.
    """
    file = pl.Path(file)
    deadline = time.monotonic() + timeout

    while time.monotonic() < deadline:
        try:
            if file.is_file():
                return True
        except FileNotFoundError:
            pass  # Any parent component may not exist yet

        time.sleep(poll_interval)

    return False
