from __future__ import annotations

import os
import re
import shlex
import shutil
import subprocess
import sys
from json import JSONDecodeError, loads
from importlib.metadata import PackageNotFoundError, version as package_version
from urllib.error import URLError
from urllib.parse import quote_plus
from urllib.request import urlopen

from sympy import latex as to_latex

from .core import evaluate, normalize_expression, relaxed_function_rewrites

PACKAGE_NAME = "philcalc"
CLI_NAME = "phil"
UPDATE_CMD = "uv tool upgrade philcalc"


def _calc_version() -> str:
    try:
        return package_version(PACKAGE_NAME)
    except PackageNotFoundError:
        return "dev"


VERSION = _calc_version()
PROMPT = f"{CLI_NAME}> "
HELP_TEXT = (
    f"{CLI_NAME} v{VERSION} - symbolic CLI calculator\n"
    "\n"
    "usage:\n"
    f"  {CLI_NAME} [--format MODE] [--latex|--latex-inline|--latex-block] [--strict] [--no-simplify] [--explain-parse] [--wa] [--copy-wa] [--color MODE] '<expression>'\n"
    f"  {CLI_NAME}\n"
    f"  {CLI_NAME} :examples\n"
    "\n"
    "options:\n"
    "  --format MODE   output mode: plain, pretty, latex, latex-inline, latex-block\n"
    "  --latex         print raw LaTeX (no delimiters)\n"
    "  --latex-inline  print LaTeX wrapped as $...$\n"
    "  --latex-block   print LaTeX wrapped as $$...$$\n"
    "  --strict        disable relaxed input parsing\n"
    "  --no-simplify   skip simplify() on parsed expressions\n"
    "  --explain-parse show normalized expression on stderr\n"
    "  --wa            always print WolframAlpha equivalent link\n"
    "  --copy-wa       copy WolframAlpha link to clipboard when shown\n"
    "  --color MODE    diagnostics color: auto, always, never\n"
    "\n"
    "upgrade:\n"
    f"  {UPDATE_CMD}\n"
    "\n"
    "repl commands:\n"
    "  :h, :help      show this help\n"
    "  :examples      show example expressions\n"
    "  :tutorial      show guided tour for new users\n"
    "  :ode           show ODE quick reference and templates\n"
    "  :next          next tutorial step (after :tutorial)\n"
    "  :repeat        repeat current tutorial step\n"
    "  :done          exit tutorial mode\n"
    "  :v, :version   show version\n"
    "  :update, :check  check current vs latest version\n"
    "  :q, :quit, :x  quit\n"
    "\n"
    "quick examples:\n"
    "  (1 - 25e^5)e^{-5t} + (25e^5 - 1)t e^{-5t} + t e^{-5t} ln(t)\n"
    "  d(x^3 + 2*x, x)\n"
    "  int(sin(x), x)\n"
    "  solve(x^2 - 4, x)\n"
    "  N(pi, 20)"
)
EXAMPLES_TEXT = (
    "examples:\n"
    "  1/3 + 1/6\n"
    "  d(x^3 + 2*x, x)\n"
    "  int(sin(x), x)\n"
    "  solve(x^2 - 4, x)\n"
    "  (854/2197)e^{8t}+(1343/2197)e^{-5t}+((9/26)t^2 -(9/169)t)e^{8t}\n"
    "  dsolve(Eq(f(t).diff(t), f(t)), f(t))\n"
    "  N(pi, 20)"
)
TUTORIAL_TEXT = (
    "guided tour:\n"
    "  1) Start: phil '2+2'\n"
    "  2) Core ops: phil 'd(x^3 + 2*x, x)' / phil 'int(sin(x), x)' / phil 'solve(x^2 - 4, x)'\n"
    "  3) REPL: phil, then try d(x^2, x), A=Matrix([[1,2],[3,4]]), det(A), ans+1\n"
    "  4) ODE input: dy/dx = y ; y' = y ; \\frac{dy}{dx} = y\n"
    "  5) Solve ODE: dsolve(Eq(d(y(x), x), y(x)), y(x))\n"
    "  6) LaTeX style: $d(x^2, x)$ ; \\sin(x)^2 + \\cos(x)^2\n"
    "  7) Use :examples for more patterns\n"
    "full tutorial: TUTORIAL.md"
)
ODE_TEXT = (
    "ode quick reference:\n"
    "  why Eq(...): dsolve expects an equation object, not raw 'lhs = rhs' text\n"
    "  why y(x): dsolve expects function notation for dependent variables\n"
    "\n"
    "common input forms accepted:\n"
    "  dy/dx = y\n"
    "  y' = y\n"
    "  \\frac{dy}{dx} = y\n"
    "\n"
    "canonical dsolve pattern:\n"
    "  dsolve(Eq(d(y(x), x), y(x)), y(x))\n"
    "\n"
    "templates:\n"
    "  separable: dsolve(Eq(d(y(x), x), x*y(x)), y(x))\n"
    "  linear:    dsolve(Eq(d(y(x), x) + y(x), exp(x)), y(x))\n"
    "  2nd order: dsolve(Eq(d(d(y(x), x), x) + y(x), 0), y(x))\n"
    "  with ICs:  dsolve(Eq(d(y(x), x), y(x)), y(x), ics={y(0): 1})"
)
TUTORIAL_STEPS = (
    "step 1/6\n  run: 1/3 + 1/6\n  expect: 1/2",
    "step 2/6\n  run: d(x^3 + 2*x, x)\n  expect: 3*x**2 + 2",
    "step 3/6\n  run: int(sin(x), x)\n  expect: -cos(x)",
    "step 4/6\n  run: dy/dx = y\n  expect: Eq(y(x), Derivative(y(x), x))",
    "step 5/6\n  run: dsolve(Eq(d(y(x), x), y(x)), y(x))\n  expect: Eq(y(x), C1*exp(x))",
    "step 6/6\n  run: --latex d(x^2, x)\n  expect: 2 x",
)
COLOR_MODES = {"auto", "always", "never"}
ANSI_COLORS = {
    "red": "\033[31m",
    "yellow": "\033[33m",
    "bold": "\033[1m",
}
ANSI_RESET = "\033[0m"


def _wolframalpha_url(expr: str) -> str:
    return f"https://www.wolframalpha.com/input?i={quote_plus(expr)}"


def _is_complex_expression(expr: str) -> bool:
    if len(expr) >= 40:
        return True
    markers = ("d(", "int(", "solve(", "dsolve(", "Eq(", "ln(", "log(", "^", "{", "}", "e^{")
    return any(marker in expr for marker in markers)


def _format_clickable_link(label: str, url: str) -> str:
    if not sys.stderr.isatty() or os.getenv("TERM") == "dumb":
        return url
    esc = "\033"
    return f"{esc}]8;;{url}{esc}\\{label}{esc}]8;;{esc}\\"


def _should_use_color(stream, color_mode: str) -> bool:
    if color_mode not in COLOR_MODES:
        return False
    if color_mode == "never":
        return False
    if color_mode == "always":
        return True
    if os.getenv("NO_COLOR") is not None:
        return False
    if os.getenv("TERM") == "dumb":
        return False
    return stream.isatty()


def _style(text: str, *, color: str, stream, color_mode: str) -> str:
    if not _should_use_color(stream, color_mode):
        return text
    code = ANSI_COLORS.get(color)
    if code is None:
        return text
    return f"{code}{text}{ANSI_RESET}"


def _copy_to_clipboard(text: str) -> bool:
    commands = (
        ["pbcopy"],
        ["wl-copy"],
        ["xclip", "-selection", "clipboard"],
        ["xsel", "--clipboard", "--input"],
        ["clip"],
    )
    for cmd in commands:
        if shutil.which(cmd[0]) is None:
            continue
        try:
            subprocess.run(cmd, input=text, text=True, check=True, capture_output=True)
            return True
        except Exception:
            continue
    return False


def _print_wolfram_hint(expr: str, copy_link: bool = False, color_mode: str = "auto") -> None:
    url = _wolframalpha_url(expr)
    # Always print raw URL for maximum terminal compatibility (e.g., iTerm2 auto-linking).
    print(
        _style(f"hint: try WolframAlpha: {url}", color="yellow", stream=sys.stderr, color_mode=color_mode),
        file=sys.stderr,
    )
    if copy_link:
        if _copy_to_clipboard(url):
            print(
                _style("hint: WolframAlpha link copied to clipboard", color="yellow", stream=sys.stderr, color_mode=color_mode),
                file=sys.stderr,
            )
        else:
            print(
                _style("hint: clipboard copy unavailable on this system", color="yellow", stream=sys.stderr, color_mode=color_mode),
                file=sys.stderr,
            )


def _print_error(exc: Exception, expr: str | None = None, color_mode: str = "auto") -> None:
    print(_style(f"E: {exc}", color="red", stream=sys.stderr, color_mode=color_mode), file=sys.stderr)
    hint = _hint_for_error(str(exc), expr=expr)
    if hint:
        print(_style(f"hint: {hint}", color="yellow", stream=sys.stderr, color_mode=color_mode), file=sys.stderr)
    if expr:
        _print_wolfram_hint(expr, color_mode=color_mode)


def _print_relaxed_rewrite_hints(expr: str, relaxed: bool, color_mode: str) -> None:
    if not relaxed:
        return
    seen: set[tuple[str, str]] = set()
    for original, rewritten in relaxed_function_rewrites(expr):
        if (original, rewritten) in seen:
            continue
        seen.add((original, rewritten))
        print(
            _style(
                f"hint: interpreted '{original}' as '{rewritten}'",
                color="yellow",
                stream=sys.stderr,
                color_mode=color_mode,
            ),
            file=sys.stderr,
        )


def _print_parse_explanation(expr: str, relaxed: bool, enabled: bool, color_mode: str) -> None:
    if not enabled:
        return
    normalized = normalize_expression(expr, relaxed=relaxed)
    print(
        _style(f"hint: parsed as: {normalized}", color="yellow", stream=sys.stderr, color_mode=color_mode),
        file=sys.stderr,
    )


def _hint_for_error(message: str, expr: str | None = None) -> str | None:
    text = message.lower()
    if "unexpected eof" in text:
        if expr and ("/d" in expr or expr.strip().startswith("d(")):
            return "derivative syntax: d(expr, var) or d(sin(x))/dx or df(t)/dt"
        return "check missing closing ')' or unmatched quote"
    if "invalid syntax" in text:
        if expr:
            compact = re.sub(r"\s+", "", expr)
            if expr.strip().startswith("Eq(") and not _eq_has_top_level_comma(expr):
                return "Eq syntax: Eq(lhs, rhs), for example Eq(d(y(x), x), y(x))"
            if "dsolve(" in compact and "eq(" not in compact:
                return "dsolve expects an equation: use dsolve(Eq(...), y(x))"
            if "\\frac" in expr:
                return "LaTeX fraction syntax: \\frac{numerator}{denominator}"
            if "d(" in compact or re.search(r"\bd[A-Za-z0-9_]+/d[A-Za-z0-9_]+\b", compact):
                return "derivative syntax: d(expr, var) or d(sin(x))/dx or df(t)/dt"
            if "matrix(" in compact.lower():
                return "matrix syntax: Matrix([[1,2],[3,4]])"
        return "check commas and brackets; try :examples for working patterns"
    if "name '" in text and "is not defined" in text:
        if expr and ("/d" in expr or expr.strip().startswith("d")):
            return "derivative syntax: d(expr, var) or d(sin(x))/dx or df(t)/dt"
        return "use one of: x y z t pi e f and documented functions"
    if "dsolve() and classify_ode() only work with functions of one variable" in text:
        return "for ODEs, use function notation: y(x) and dsolve(Eq(d(y(x), x), ...), y(x))"
    if "data type not understood" in text:
        if expr and "matrix(" in expr.lower():
            return "matrix syntax: Matrix([[1,2],[3,4]])"
    if "blocked token" in text:
        return "remove blocked patterns like '__', ';', or newlines"
    if "empty expression" in text:
        return "enter a math expression, or use :examples"
    return None


def _eq_has_top_level_comma(expr: str) -> bool:
    stripped = expr.strip()
    if not stripped.startswith("Eq("):
        return True
    inner = stripped[3:]
    if inner.endswith(")"):
        inner = inner[:-1]
    depth = 0
    for ch in inner:
        if ch == "(":
            depth += 1
        elif ch == ")":
            depth = max(0, depth - 1)
        elif ch == "," and depth == 0:
            return True
    return False


def _format_result(value, format_mode: str) -> str:
    if format_mode == "plain":
        return str(value)
    if format_mode == "pretty":
        from sympy import pretty as to_pretty

        return to_pretty(value)
    rendered = to_latex(value)
    if format_mode == "latex-inline":
        return f"${rendered}$"
    if format_mode == "latex-block":
        return f"$$\n{rendered}\n$$"
    return rendered


def _latest_pypi_version() -> str | None:
    url = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
    try:
        with urlopen(url, timeout=2.0) as response:
            payload = loads(response.read().decode("utf-8"))
        return payload.get("info", {}).get("version")
    except (OSError, URLError, TimeoutError, ValueError, JSONDecodeError):
        return None


def _print_update_status() -> None:
    if VERSION == "dev":
        print("current version: dev (local checkout)")
        print("latest version: unknown from local checkout")
        print("install latest local changes with: uv tool install --force --reinstall --refresh .")
        return

    latest = _latest_pypi_version()
    print(f"current version: {VERSION}")
    if latest is None:
        print("latest version: unavailable (offline or PyPI unreachable)")
    elif latest == VERSION:
        print(f"latest version: {latest} (up to date)")
    else:
        print(f"latest version: {latest} (update available)")
    print(f"update with: {UPDATE_CMD}")


def _parse_options(args: list[str]) -> tuple[str, bool, bool, bool, bool, bool, str, list[str]]:
    format_mode = "plain"
    relaxed = True
    simplify_output = True
    explain_parse = False
    always_wa = False
    copy_wa = False
    color_mode = "auto"
    idx = 0
    while idx < len(args) and args[idx].startswith("-"):
        arg = args[idx]
        if arg in {"-h", "--help"}:
            print(HELP_TEXT)
            raise SystemExit(0)
        if arg == "--format":
            if idx + 1 >= len(args):
                raise ValueError("missing value for --format")
            mode = args[idx + 1]
            if mode not in {"plain", "pretty", "latex", "latex-inline", "latex-block"}:
                raise ValueError(f"unknown format mode: {mode}")
            format_mode = mode
            idx += 2
            continue
        if arg.startswith("--format="):
            mode = arg.split("=", 1)[1]
            if mode not in {"plain", "pretty", "latex", "latex-inline", "latex-block"}:
                raise ValueError(f"unknown format mode: {mode}")
            format_mode = mode
            idx += 1
            continue
        if arg == "--latex":
            format_mode = "latex"
            idx += 1
            continue
        if arg == "--latex-inline":
            format_mode = "latex-inline"
            idx += 1
            continue
        if arg == "--latex-block":
            format_mode = "latex-block"
            idx += 1
            continue
        if arg == "--strict":
            relaxed = False
            idx += 1
            continue
        if arg == "--no-simplify":
            simplify_output = False
            idx += 1
            continue
        if arg == "--explain-parse":
            explain_parse = True
            idx += 1
            continue
        if arg == "--wa":
            always_wa = True
            idx += 1
            continue
        if arg == "--copy-wa":
            copy_wa = True
            idx += 1
            continue
        if arg == "--color":
            if idx + 1 >= len(args):
                raise ValueError("missing value for --color")
            mode = args[idx + 1]
            if mode not in COLOR_MODES:
                raise ValueError(f"unknown color mode: {mode}")
            color_mode = mode
            idx += 2
            continue
        if arg.startswith("--color="):
            mode = arg.split("=", 1)[1]
            if mode not in COLOR_MODES:
                raise ValueError(f"unknown color mode: {mode}")
            color_mode = mode
            idx += 1
            continue
        if arg == "--":
            idx += 1
            break
        if arg.startswith("--"):
            raise ValueError(f"unknown option: {arg}")
        break
    return format_mode, relaxed, simplify_output, explain_parse, always_wa, copy_wa, color_mode, args[idx:]


def _handle_repl_command(expr: str, color_mode: str = "auto") -> bool:
    if expr in {":q", ":quit", ":x"}:
        raise EOFError
    if expr in {":h", ":help"}:
        print(HELP_TEXT)
        return True
    if expr == ":examples":
        print(EXAMPLES_TEXT)
        return True
    if expr in {":tutorial", ":tour"}:
        print(TUTORIAL_TEXT)
        return True
    if expr == ":ode":
        print(ODE_TEXT)
        return True
    if expr in {":v", ":version"}:
        print(f"{CLI_NAME} v{VERSION}")
        return True
    if expr in {":update", ":check"}:
        _print_update_status()
        return True
    if expr.startswith(":"):
        print(_style("E: unknown command", color="red", stream=sys.stderr, color_mode=color_mode), file=sys.stderr)
        print(
            _style("hint: use :h to list commands", color="yellow", stream=sys.stderr, color_mode=color_mode),
            file=sys.stderr,
        )
        return True
    return False


def _print_tutorial_step(index: int) -> None:
    print(TUTORIAL_STEPS[index])


def _tutorial_command(expr: str, state: dict | None) -> bool:
    if state is None:
        return False
    if expr in {":tutorial", ":tour"}:
        state["active"] = True
        state["index"] = 0
        print("tutorial mode started. use :next, :repeat, :done")
        _print_tutorial_step(state["index"])
        return True
    if expr == ":next":
        if not state.get("active", False):
            print("hint: start with :tutorial", file=sys.stderr)
            return True
        nxt = state["index"] + 1
        if nxt >= len(TUTORIAL_STEPS):
            print("tutorial complete. use :done to exit tutorial mode")
            return True
        state["index"] = nxt
        _print_tutorial_step(state["index"])
        return True
    if expr == ":repeat":
        if not state.get("active", False):
            print("hint: start with :tutorial", file=sys.stderr)
            return True
        _print_tutorial_step(state["index"])
        return True
    if expr == ":done":
        if state.get("active", False):
            state["active"] = False
            print("tutorial mode ended")
        else:
            print("hint: tutorial is not active; use :tutorial", file=sys.stderr)
        return True
    return False


def _try_parse_repl_inline_options(expr: str):
    line = expr.strip()
    if line.startswith(f"{CLI_NAME} "):
        line = line[len(CLI_NAME) :].strip()
    elif not line.startswith("-"):
        return None
    try:
        tokens = shlex.split(line)
    except ValueError as exc:
        raise ValueError(f"invalid REPL option input: {exc}") from exc
    return _parse_options(tokens)


def run(argv: list[str] | None = None) -> int:
    args = sys.argv[1:] if argv is None else argv

    try:
        format_mode, relaxed, simplify_output, explain_parse, always_wa, copy_wa, color_mode, remaining = _parse_options(args)
    except SystemExit:
        return 0
    except Exception as exc:
        _print_error(exc, color_mode="auto")
        return 1

    if remaining:
        expr = " ".join(remaining)
        if expr == ":examples":
            print(EXAMPLES_TEXT)
            return 0
        if expr == ":ode":
            print(ODE_TEXT)
            return 0
        if expr in {":tutorial", ":tour"}:
            print(TUTORIAL_TEXT)
            return 0
        if expr in {":v", ":version"}:
            print(f"{CLI_NAME} v{VERSION}")
            return 0
        if expr in {":update", ":check"}:
            _print_update_status()
            return 0
        try:
            _print_relaxed_rewrite_hints(expr, relaxed, color_mode)
            _print_parse_explanation(expr, relaxed, explain_parse, color_mode)
            print(
                _format_result(
                    evaluate(expr, relaxed=relaxed, simplify_output=simplify_output),
                    format_mode,
                )
            )
            if always_wa or _is_complex_expression(expr):
                _print_wolfram_hint(expr, copy_link=copy_wa, color_mode=color_mode)
            return 0
        except Exception as exc:
            _print_error(exc, expr, color_mode=color_mode)
            return 1

    print(f"{CLI_NAME} v{VERSION} REPL. :h help, :q quit, Ctrl-D exit.")
    print(f"update: {UPDATE_CMD}")
    session_locals: dict = {}
    repl_format_mode = format_mode
    repl_relaxed = relaxed
    repl_simplify_output = simplify_output
    repl_explain_parse = explain_parse
    repl_always_wa = always_wa
    repl_copy_wa = copy_wa
    repl_color_mode = color_mode
    tutorial_state = {"active": False, "index": 0}
    expr: str | None = None
    while True:
        try:
            expr = input(PROMPT).strip()
            if not expr:
                continue
            if _tutorial_command(expr, tutorial_state):
                continue
            if _handle_repl_command(expr, color_mode=repl_color_mode):
                continue
            parsed_inline = _try_parse_repl_inline_options(expr)
            if parsed_inline is not None:
                (
                    repl_format_mode,
                    repl_relaxed,
                    repl_simplify_output,
                    repl_explain_parse,
                    repl_always_wa,
                    repl_copy_wa,
                    repl_color_mode,
                    remaining,
                ) = parsed_inline
                if not remaining:
                    print("hint: REPL options updated for this session", file=sys.stderr)
                    continue
                expr = " ".join(remaining)
            _print_relaxed_rewrite_hints(expr, repl_relaxed, repl_color_mode)
            _print_parse_explanation(expr, repl_relaxed, repl_explain_parse, repl_color_mode)
            print(
                _format_result(
                    evaluate(
                        expr,
                        relaxed=repl_relaxed,
                        session_locals=session_locals,
                        simplify_output=repl_simplify_output,
                    ),
                    repl_format_mode,
                )
            )
            if repl_always_wa or _is_complex_expression(expr):
                _print_wolfram_hint(expr, copy_link=repl_copy_wa, color_mode=repl_color_mode)
        except (EOFError, KeyboardInterrupt):
            print()
            return 0
        except Exception as exc:
            _print_error(exc, expr=expr, color_mode=repl_color_mode)


if __name__ == "__main__":
    raise SystemExit(run())
