#!/usr/bin/env python3
#
#    corindo-cli, a simple REPL AI agent.
#    Copyright (C) 2026 Ying-Chun Liu (PaulLiu) <paulliu@debian.org>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <https://www.gnu.org/licenses/>.

import fnmatch
import ipaddress
import json
import os
import pathlib
import re
import readline
import signal
import socket
import subprocess
import sys
import tempfile
import tomllib
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
from openai import OpenAI


def load_config():
  """Loads configuration from ~/.config/corindo-cli/config.toml,

  with fallback to legacy ~/.debgpt/config.toml.
  """
  config_path = pathlib.Path.home() / ".config" / "corindo-cli" / "config.toml"

  if not config_path.exists():
    # Fallback to legacy path
    legacy_path = pathlib.Path.home() / ".debgpt" / "config.toml"
    if legacy_path.exists():
      config_path = legacy_path
    else:
      print(f"❌ Configuration file not found:")
      print(f"   - Tried: ~/.config/corindo-cli/config.toml")
      print(f"   - Tried: ~/.debgpt/config.toml")
      print(
          "Please create one of them and fill in 'openai_api_key',"
          " 'openai_base_url', and 'openai_model'."
      )
      sys.exit(1)

  try:
    with open(config_path, "rb") as f:
      return tomllib.load(f), config_path
  except Exception as e:
    print(f"❌ Failed to parse TOML config file ({config_path}): {e}")
    sys.exit(1)


# Load configuration and initialize the OpenAI client
config, loaded_path = load_config()
API_KEY = config.get("openai_api_key")
BASE_URL = config.get("openai_base_url")
MODEL_NAME = config.get("openai_model", "gpt-4o")

if not API_KEY:
  print("❌ Configuration error: Missing required field 'openai_api_key'")
  sys.exit(1)

client_kwargs = {"api_key": API_KEY}
if BASE_URL:
  client_kwargs["base_url"] = BASE_URL

client = OpenAI(**client_kwargs)


# Give up after this many prune-and-retry rounds instead of spinning forever.
MAX_CONTEXT_OVERFLOW_RETRIES = 3

# Tool outputs shorter than this are not worth pruning.
PRUNE_MIN_CHARS = 200


def prune_tool_outputs(messages):
  """Shrinks bulky tool outputs, preserving the system prompt and first turn.

  The newest message is never touched: it is the freshest piece of context and
  usually the reason the request was just made. Returns how many messages were
  actually shrunk, so the caller can detect that pruning no longer buys us
  anything and stop retrying.
  """
  last_index = len(messages) - 1
  pruned_count = 0
  for idx, m in enumerate(messages):
    if idx == 0 or idx < 10 or idx == last_index:
      continue
    if not isinstance(m, dict) or m.get("role") != "tool":
      continue
    content = m.get("content") or ""
    if len(content) > PRUNE_MIN_CHARS:
      m["content"] = (
          "[Pruned old tool output to resolve 413 error."
          f" Original length: {len(content)} chars]"
      )
      pruned_count += 1
  return pruned_count


def _message_field(message, key, default=None):
  """Reads a field from a message that may be a dict or an SDK object."""
  if isinstance(message, dict):
    return message.get(key, default)
  return getattr(message, key, default)


def repair_tool_call_pairing(messages):
  """Fills in a placeholder answer for any tool_call left unanswered.

  The API rejects the whole request with 400 when a single tool_call has no
  matching tool message, and the error does not say which call is at fault.
  So one slip - a tool that crashes before answering, a session file resumed
  from disk, a Ctrl-C at the wrong moment - makes every later request fail
  with no way back except /save-session. A placeholder is always better than
  a conversation that can never be continued.

  Returns how many placeholders were added, so the caller can say so instead
  of silently papering over a bug.
  """
  repaired = 0
  index = 0
  while index < len(messages):
    message = messages[index]
    tool_calls = _message_field(message, "tool_calls")
    if _message_field(message, "role") != "assistant" or not tool_calls:
      index += 1
      continue

    # The answers to this assistant message must sit right after it.
    answered = set()
    following = index + 1
    while (following < len(messages)
           and _message_field(messages[following], "role") == "tool"):
      answered.add(_message_field(messages[following], "tool_call_id"))
      following += 1

    for tool_call in tool_calls:
      call_id = _message_field(tool_call, "id")
      if call_id in answered:
        continue
      function = _message_field(tool_call, "function")
      messages.insert(following, {
          "role": "tool",
          "tool_call_id": call_id,
          "name": _message_field(function, "name"),
          "content": (
              "Error: no result was recorded for this tool call."
              " It never finished, so treat it as failed."
          ),
      })
      following += 1
      repaired += 1

    index = following

  return repaired


# Helper to robustly call LLM and handle 413 Request Entity Too Large errors
def call_llm(messages, tools=None, tool_choice=None):
  """Calls OpenAI API and prunes old tool output if the context overflows.

  Pruning happens in place, so every assistant message keeps its tool_calls and
  every tool_calls entry keeps its matching tool response. If there is nothing
  left to prune, we stop instead of re-sending the very same request forever.
  """
  repaired = repair_tool_call_pairing(messages)
  if repaired:
    print(f"\n🔧 Repaired {repaired} tool call(s) that had no result"
          " recorded. They were reported to the model as failed.")

  overflow_retries = 0
  while True:
    try:
      kwargs = {"model": MODEL_NAME, "messages": messages}
      if tools is not None:
        kwargs["tools"] = tools
      if tool_choice is not None:
        kwargs["tool_choice"] = tool_choice
      return client.chat.completions.create(**kwargs)
    except Exception as e:
      err_str = str(e)
      if "413" not in err_str and "Request Entity Too Large" not in err_str:
        raise

      print("\n⚠️ [Context Overflow] Server returned 413"
            " (Request Entity Too Large).")
      if len(messages) <= 1:
        raise

      if overflow_retries >= MAX_CONTEXT_OVERFLOW_RETRIES:
        raise RuntimeError(
            "Context is still too large after pruning old tool output"
            f" {overflow_retries} times. Use /save-session and start a new"
            " session."
        ) from e

      print("🧹 Pruning old tool output (preserving system prompt and first"
            " 5 turns)...")
      pruned_count = prune_tool_outputs(messages)
      if pruned_count == 0:
        raise RuntimeError(
            "Context is too large and there is no old tool output left to"
            " prune (the newest messages alone exceed the limit). Use"
            " /save-session and start a new session."
        ) from e

      overflow_retries += 1
      print(f"🔄 Pruned {pruned_count} tool output(s),"
            " retrying with optimized context...\n")


def describe_exit_status(returncode):
  """Formats a process exit status so the model can tell success from failure.

  Without this the model only ever sees the command's text output, so it has to
  guess: a failing build and a passing build look equally fine, and a command
  that prints nothing looks perfect.
  """
  if returncode is None:
    return "[exit status: unknown]"
  if returncode < 0:
    try:
      signal_name = signal.Signals(-returncode).name
    except ValueError:
      signal_name = "unknown signal"
    return f"[exit status: killed by signal {-returncode} ({signal_name})]"
  if returncode == 0:
    return "[exit status: 0 (success)]"
  return f"[exit status: {returncode} (failure)]"


# How long a stopped command gets to exit politely before it is SIGKILLed.
TERMINATE_GRACE_SECONDS = 5


def terminate_process_group(process):
  """Stops a tool child and everything it spawned, then reaps it.

  subprocess.Popen(..., start_new_session=True) puts the child in its own
  session, so its pid is also its process group id and os.killpg() reaches the
  whole tree. terminate() alone only signals the `sh -c` wrapper: a Ctrl-C
  during `debuild` left make, dpkg and the compiler running, still writing to
  a pipe nobody reads any more.

  Two waits are needed, not one: wait() reaps the child so it does not linger
  as a zombie, and the second one after SIGKILL covers descendants that
  ignored SIGTERM.
  """
  if process is None or process.poll() is not None:
    # poll() reaps the child, so once it is non-None the pid - and with it the
    # process group id - may already belong to an unrelated process. Never
    # signal in that case.
    return

  try:
    pgid = os.getpgid(process.pid)
  except OSError:
    pgid = None

  if pgid is not None:
    try:
      os.killpg(pgid, signal.SIGTERM)
    except OSError:
      pass

  try:
    process.wait(timeout=TERMINATE_GRACE_SECONDS)
    return
  except subprocess.TimeoutExpired:
    pass

  if pgid is not None:
    try:
      os.killpg(pgid, signal.SIGKILL)
    except OSError:
      pass

  try:
    process.wait(timeout=TERMINATE_GRACE_SECONDS)
  except subprocess.TimeoutExpired:
    # Nothing else to try; the child is unkillable (D state) and reporting
    # that is better than blocking the REPL forever.
    pass


# Strict user confirmation helper (requires explicit Y or N)
def get_user_confirmation(prompt_text: str) -> bool:
  while True:
    try:
      user_input = input(prompt_text).strip().upper()
      if user_input == "Y":
        return True
      elif user_input == "N":
        return False
      else:
        print(
            "⚠️ Invalid input: Please enter 'Y' or 'N' (case-insensitive)."
        )
    except (KeyboardInterrupt, EOFError):
      print("\nOperation cancelled by user.")
      return False


# Smart truncation helper to protect the model context window
def truncate_output(output: str, max_chars: int = 8000) -> str:
  """Truncates output by preserving the head and tail if it exceeds max_chars."""
  if len(output) <= max_chars:
    return output

  half = max_chars // 2
  head = output[:half]
  tail = output[-half:]

  return (
      f"{head}\n\n... [Output truncated: omitted approx"
      f" {len(output) - max_chars} characters in between] ...\n\n{tail}"
  )


# Scheme whitelist for fetch_webpage. urllib ships a FileHandler, so without
# this check a "url" of file:///home/you/.ssh/id_rsa would be served happily and
# would bypass every Y/N confirmation prompt in this program.
ALLOWED_URL_SCHEMES = ("http", "https")

# Ports used when the URL does not spell one out, needed to resolve the host.
DEFAULT_PORTS = {"http": 80, "https": 443}


def check_url_allowed(url):
  """Decides whether fetch_webpage may talk to this URL.

  Returns (True, "") when the URL is an http(s) URL whose host resolves to
  public addresses only, otherwise (False, reason).

  Two separate threats are covered here:
    * file://, data:, ftp:// etc. would read local resources, which is exactly
      what the confirmation prompts are supposed to gate.
    * loopback / private / link-local targets (127.0.0.1, 192.168.x.x,
      169.254.169.254 cloud metadata, ::1, ...) would let the agent poke at
      services that are not reachable from outside.

  Known limitation: the host is resolved once for this check and again by
  urllib when it connects, so a DNS server that answers differently the second
  time (DNS rebinding) could still slip through. Closing that would require
  pinning the resolved address into the connection.
  """
  try:
    parsed = urllib.parse.urlsplit(url)
    scheme = parsed.scheme.lower()
    host = parsed.hostname
    port = parsed.port
  except ValueError as exc:
    return False, f"the URL cannot be parsed: {exc}"

  if scheme not in ALLOWED_URL_SCHEMES:
    shown = scheme or "(none)"
    return False, (
        f"scheme '{shown}' is not allowed, only http and https are"
        " (local file access is blocked on purpose)"
    )

  if not host:
    return False, "the URL has no host name"

  try:
    address_infos = socket.getaddrinfo(
        host,
        port or DEFAULT_PORTS[scheme],
        proto=socket.IPPROTO_TCP,
    )
  except socket.gaierror as exc:
    return False, f"host '{host}' cannot be resolved: {exc}"

  if not address_infos:
    return False, f"host '{host}' did not resolve to any address"

  for info in address_infos:
    try:
      ip = ipaddress.ip_address(info[4][0])
    except ValueError:
      return False, f"host '{host}' resolved to an unparsable address"
    # ::ffff:127.0.0.1 must be judged as the IPv4 address it wraps.
    if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
      ip = ip.ipv4_mapped
    if not ip.is_global or ip.is_multicast:
      return False, (
          f"host '{host}' resolves to the non-public address {ip}, which is"
          " blocked on purpose"
      )

  return True, ""


class PublicOnlyRedirectHandler(urllib.request.HTTPRedirectHandler):
  """Applies check_url_allowed() to every redirect target as well.

  Checking only the URL the model asked for is not enough: a public page is
  free to answer 302 Location: http://169.254.169.254/ and urllib would follow
  it. (Stock urllib also still permits ftp:// redirects.)
  """

  def redirect_request(self, req, fp, code, msg, headers, newurl):
    allowed, reason = check_url_allowed(newurl)
    if not allowed:
      print(f"🚫 Blocked redirect to {newurl}: {reason}")
      raise urllib.error.HTTPError(
          newurl, code, f"Blocked redirect: {reason}", headers, fp)
    return super().redirect_request(req, fp, code, msg, headers, newurl)


# Define function calling tools
tools = [
    {
        "type": "function",
        "function": {
            "name": "run_shell_command",
            "description": (
                "Executes a shell command, such as debuild, git status, cargo"
                " build, etc. stdout and stderr are merged into one stream, and"
                " the exit status is appended at the end as"
                " '[exit status: N (success|failure)]', so read that line before"
                " concluding whether the command worked; output is truncated to"
                " roughly 8000 characters. stdin is /dev/null, so interactive"
                " commands (editors, pagers, anything that prompts) get EOF and"
                " fail: pass non-interactive flags instead (-y, -m, --batch,"
                " DEBIAN_FRONTEND=noninteractive). IMPORTANT: Do NOT run"
                " commands in the background using '&' (e.g., no 'cmd &'), as"
                " this breaks the REPL stdin stream and corrupts terminal"
                " interaction. Always run synchronously."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The bash command to execute",
                    }
                },
                "required": ["command"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": (
                "Reads content of a specified file path. Optionally accepts"
                " start_line and end_line (1-indexed) to read a specific range."
                " Output is truncated to roughly 8000 characters, keeping the"
                " head and the tail with the middle omitted, so pass"
                " start_line/end_line when reading a large file."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to read",
                    },
                    "start_line": {
                        "type": "integer",
                        "description": "Optional 1-indexed starting line number (inclusive)",
                    },
                    "end_line": {
                        "type": "integer",
                        "description": "Optional 1-indexed ending line number (inclusive)",
                    },
                },
                "required": ["filepath"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "list_dir",
            "description": (
                "Lists one directory: name, type (dir/file/link) and size,"
                " directories first. Hidden entries are skipped unless"
                " show_hidden is true; at most 200 entries are shown. Use this"
                " to orient yourself instead of shelling out to ls - it is"
                " read-only and needs no confirmation."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "path": {
                        "type": "string",
                        "description": "Directory to list (default: current directory)",
                    },
                    "show_hidden": {
                        "type": "boolean",
                        "description": "Also show entries starting with a dot (default: false)",
                    },
                },
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "grep",
            "description": (
                "Searches file contents with a Python regular expression and"
                " returns 'path:line: text'. Skips .git and other cache"
                " directories, binary files, and files larger than 2MB. Stops"
                " at 100 matches by default and says so. Read-only and needs no"
                " confirmation, so prefer it over running grep in the shell."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Python regular expression to search for",
                    },
                    "path": {
                        "type": "string",
                        "description": "File or directory to search (default: current directory)",
                    },
                    "include": {
                        "type": "string",
                        "description": "Only search files whose name matches this glob, e.g. '*.py'",
                    },
                    "ignore_case": {
                        "type": "boolean",
                        "description": "Case-insensitive match (default: false)",
                    },
                    "max_results": {
                        "type": "integer",
                        "description": "Maximum matches to return, capped at 100 (default: 100)",
                    },
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "edit_file",
            "description": (
                "Edits an existing text file by replacing an exact string."
                " Prefer this over write_file when changing a few lines: the"
                " rest of the file is left untouched. old_string must match"
                " the file byte for byte, including indentation, and must"
                " occur exactly once unless replace_all is true."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to edit",
                    },
                    "old_string": {
                        "type": "string",
                        "description": (
                            "The exact text to replace, including indentation."
                            " Include enough surrounding lines to make it"
                            " unique in the file."
                        ),
                    },
                    "new_string": {
                        "type": "string",
                        "description": (
                            "The replacement text. Pass an empty string to"
                            " delete the matched text."
                        ),
                    },
                    "replace_all": {
                        "type": "boolean",
                        "description": (
                            "Replace every occurrence instead of requiring a"
                            " unique match. Defaults to false."
                        ),
                    },
                },
                "required": ["filepath", "old_string", "new_string"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "write_file",
            "description": (
                "Writes or overwrites content into a specified file path"
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "filepath": {
                        "type": "string",
                        "description": "File path to write",
                    },
                    "content": {
                        "type": "string",
                        "description": "Content to write",
                    },
                },
                "required": ["filepath", "content"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "fetch_webpage",
            "description": (
                "Fetches and extracts the text content of a public webpage given a URL."
                " Only http:// and https:// URLs on public hosts are accepted;"
                " file:// and private/loopback addresses are refused."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "url": {
                        "type": "string",
                        "description": "The public URL to fetch",
                    }
                },
                "required": ["url"],
            },
        },
    },
]


def iter_occurrences(haystack, needle):
  """Yields the index of every non-overlapping occurrence of needle."""
  start = 0
  while True:
    index = haystack.find(needle, start)
    if index < 0:
      return
    yield index
    start = index + len(needle)


def format_edit_preview(text, marker, max_lines=6):
  """Renders the first few lines of an edit so the prompt stays readable."""
  lines = text.splitlines() or [""]
  rendered = "\n".join(f"      {marker} {line}" for line in lines[:max_lines])
  if len(lines) > max_lines:
    rendered += f"\n      {marker} ... ({len(lines) - max_lines} more lines)"
  return rendered


def write_file_atomically(filepath, content):
  """Writes content next to filepath, then renames it into place.

  A plain open(filepath, "w") truncates first, so any failure halfway through
  leaves the original file destroyed. Writing a sibling temporary file and
  renaming it means the file is either fully old or fully new. The original
  mode (including the executable bit) is carried over.
  """
  directory = os.path.dirname(os.path.abspath(filepath))
  original_mode = os.stat(filepath).st_mode & 0o7777

  fd, temp_path = tempfile.mkstemp(
      dir=directory, prefix=".corindo-edit-", suffix=".tmp")
  try:
    with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
      f.write(content)
      f.flush()
      os.fsync(f.fileno())
    os.chmod(temp_path, original_mode)
    os.replace(temp_path, filepath)
  except BaseException:
    try:
      os.unlink(temp_path)
    except OSError:
      pass
    raise


class ToolDenied(Exception):
  """Raised when the user refuses a tool call.

  A dedicated exception is used instead of a magic string in the tool output,
  because that output is arbitrary content: a file that merely *mentions* the
  old "Execution cancelled" phrase used to be mistaken for a refusal, which
  silently dropped the remaining tool calls of the same batch.
  """


class ToolInterrupted(Exception):
  """Raised when the user stops a running command with Ctrl-C.

  Deliberately not a denial: the command was approved, the user just did not
  want to wait for it any more.
  """


# Commands that are refused outright, before the user is even asked.
#
# This is a speed bump for obvious slips, NOT a security boundary: text
# matching can never be one. It misses real destruction ("rm -rf /*",
# "dd of=/dev/sda", "curl ... | sh") and, while it used to be a substring
# match, it blocked innocent commands that merely mentioned a pattern
# ("grep -rn mkfs /usr/share/doc"). Matching the *whole* command after
# whitespace normalisation keeps the false positives near zero; the coverage
# stays intentionally tiny.
#
# A real boundary is a sandbox, not a list. See README note
# about running this agent inside one.
CATASTROPHIC_COMMANDS = frozenset({
    "rm -rf /",
    "rm -fr /",
    "rm -rf /*",
    "rm -rf --no-preserve-root /",
    "mkfs",
    ":(){ :|:& };:",
})


# ---------------------------------------------------------------------------
# Read-only tools: list_dir and grep.
#
# Neither asks for a Y/N confirmation, which is the whole point: inspecting a
# tree should not cost one prompt per command, and most of what an agent does
# is look around. The price of that is that these two must be unable to change
# anything - no writes, no process spawn - and must not become a way around
# the confirmation wall for the interesting files.
# ---------------------------------------------------------------------------

# Directories that are never worth searching.
GREP_SKIP_DIRS = frozenset({
    ".git", ".hg", ".svn", ".bzr",
    "__pycache__", ".mypy_cache", ".pytest_cache", ".tox", ".venv",
    "node_modules",
})

# How much a single call may return.
MAX_LIST_ENTRIES = 200
MAX_GREP_MATCHES = 100
# Two independent guards, because they bound different things: bytes bound the
# regex work, the file count bounds the syscalls. Calibrated so that a bad case
# (the whole of /usr/include) still answers in a couple of seconds.
MAX_GREP_FILES = 40000
MAX_GREP_TOTAL_BYTES = 64 * 1024 * 1024
GREP_MAX_FILE_BYTES = 2 * 1024 * 1024
GREP_MAX_LINE_CHARS = 300

# Credential locations the read-only tools refuse to look at. Like
# CATASTROPHIC_COMMANDS this is a speed bump, not a boundary - the sandbox in
# README.md is the boundary. The case it is aimed at is a bare
# `grep -r sk- ~` pulling the API key out of the config file into the
# transcript, and from there to the model provider, with no prompt at all.
SENSITIVE_HOME_ENTRIES = (
    ".ssh", ".gnupg", ".pki", ".netrc", ".aws", ".docker/config.json",
    ".config/corindo-cli", ".debgpt",
)


def human_size(num_bytes):
  """Renders a byte count the way `ls -h` would."""
  size = float(num_bytes)
  for unit in ("B", "K", "M"):
    if size < 1024:
      return f"{int(size)}B" if unit == "B" else f"{size:.1f}{unit}"
    size /= 1024
  return f"{size:.1f}G"


def as_bool(value):
  """Interprets a JSON-ish flag, because models do send "true" as a string.

  bool("false") is True, so a plain bool() would read show_hidden="false" as
  an instruction to show hidden files.
  """
  if isinstance(value, bool):
    return value
  if isinstance(value, (int, float)):
    return value != 0
  if isinstance(value, str):
    return value.strip().lower() in ("1", "true", "yes", "y", "on")
  return False


def blocked_read_paths():
  """Absolute paths the read-only tools must not descend into."""
  home = pathlib.Path.home()
  return [str(home / entry) for entry in SENSITIVE_HOME_ENTRIES]


def matching_sensitive_entry(path, blocked_paths=None):
  """The credential directory that contains path, or None.

  The comparison is lexical on absolute paths, so a symlink pointing into one
  of them is not caught. read_file() already opens whatever path the model
  names, so the point of this guard is a bare `grep -r secret ~`, not closing
  every route to a file that the model clearly has to be told about.

  blocked_paths may be passed in when checking many entries in a row, because
  recomputing it per file showed up in the profile of a large grep.
  """
  try:
    candidate = os.path.abspath(os.fspath(pathlib.Path(path).expanduser()))
  except (OSError, ValueError, TypeError):
    return None
  for blocked in (blocked_paths or blocked_read_paths()):
    if candidate == blocked or candidate.startswith(blocked + os.sep):
      return blocked
  return None


def check_read_path_allowed(raw_path):
  """Returns (True, "") or (False, reason) for the read-only tools."""
  blocked = matching_sensitive_entry(raw_path)
  if blocked is None:
    return True, ""
  return False, (
      f"{pathlib.Path(raw_path).expanduser()} is inside {blocked}, which"
      " holds credentials. These tools never look there without a"
      " confirmation prompt; ask the user to run the command with"
      " run_shell_command instead"
  )


def describe_dir_entry(entry):
  """One line per directory entry, in the spirit of `ls -lFh`."""
  try:
    if entry.is_symlink():
      return f"[link] {entry.name} -> {os.readlink(entry.path)}"
    if entry.is_dir():
      return f"[dir]  {entry.name}/"
    return f"[file] {human_size(entry.stat().st_size)}  {entry.name}"
  except OSError as exc:
    return f"[?]    {entry.name} ({exc.strerror or exc})"


def safe_is_dir(entry):
  try:
    return entry.is_dir()
  except OSError:
    return False


def tool_list_dir(arguments):
  """Lists one directory. Read-only, so it never asks for confirmation."""
  raw_path = arguments.get("path") or "."
  show_hidden = as_bool(arguments.get("show_hidden", False))

  allowed, reason = check_read_path_allowed(raw_path)
  if not allowed:
    return f"Error: refused to list {raw_path!r} because {reason}."

  path = pathlib.Path(raw_path).expanduser()
  try:
    with os.scandir(path) as entries:
      entries = list(entries)
  except FileNotFoundError:
    return f"Error: no such directory: {raw_path}"
  except NotADirectoryError:
    return f"Error: {raw_path} is not a directory; use read_file for files."
  except PermissionError:
    return f"Error: permission denied while listing {raw_path}"
  except OSError as exc:
    return f"Error listing {raw_path}: {exc}"

  hidden = [e for e in entries if e.name.startswith(".")]
  if not show_hidden:
    entries = [e for e in entries if not e.name.startswith(".")]
  entries.sort(key=lambda e: (not safe_is_dir(e), e.name.lower()))

  shown = entries[:MAX_LIST_ENTRIES]
  lines = [f"[{os.path.abspath(path)} - {len(entries)} entries]"]
  if not shown:
    lines.append("(empty directory)")
  lines.extend(describe_dir_entry(entry) for entry in shown)

  if len(entries) > len(shown):
    lines.append(f"... {len(entries) - len(shown)} more entries not shown")
  if hidden and not show_hidden:
    lines.append(f"... {len(hidden)} hidden entries not shown"
                 " (pass show_hidden=true)")
  return truncate_output("\n".join(lines), max_chars=8000)


def iter_grep_files(root, include):
  """Yields files worth reading under root, skipping junk directories.

  Credential directories are pruned here and not only at the root, because
  that is where they actually get reached: `grep secret ~` starts in an
  ordinary directory and walks into ~/.ssh a moment later.
  """
  if root.is_file():
    yield root
    return
  blocked_paths = blocked_read_paths()
  for dirpath, dirnames, filenames in os.walk(root):
    keep = []
    for name in sorted(dirnames):
      if name in GREP_SKIP_DIRS:
        continue
      if matching_sensitive_entry(os.path.join(dirpath, name),
                                  blocked_paths) is not None:
        continue
      keep.append(name)
    dirnames[:] = keep

    for name in sorted(filenames):
      if include and not fnmatch.fnmatch(name, include):
        continue
      candidate = pathlib.Path(dirpath) / name
      if matching_sensitive_entry(candidate, blocked_paths) is not None:
        continue
      yield candidate


def tool_grep(arguments):
  """Searches file contents with a regex. Read-only, never confirms."""
  pattern = arguments.get("pattern")
  if not pattern:
    return "Error: 'pattern' is required (a regular expression)."

  raw_path = arguments.get("path") or "."
  include = arguments.get("include") or None
  flags = re.IGNORECASE if as_bool(arguments.get("ignore_case")) else 0
  try:
    max_results = int(arguments.get("max_results") or MAX_GREP_MATCHES)
  except (TypeError, ValueError):
    max_results = MAX_GREP_MATCHES
  max_results = max(1, min(max_results, MAX_GREP_MATCHES))

  try:
    regex = re.compile(pattern, flags)
  except re.error as exc:
    return f"Error: {pattern!r} is not a valid regular expression: {exc}"

  allowed, reason = check_read_path_allowed(raw_path)
  if not allowed:
    return f"Error: refused to search {raw_path!r} because {reason}."

  root = pathlib.Path(raw_path).expanduser()
  if not root.exists():
    return f"Error: no such file or directory: {raw_path}"
  # Absolute, so the per-entry sensitive-path checks can compare paths.
  root = pathlib.Path(os.path.abspath(root))

  matches = []
  scanned = skipped_binary = skipped_big = unreadable = 0
  scanned_bytes = 0
  cut_short = None

  for file_path in iter_grep_files(root, include):
    if scanned >= MAX_GREP_FILES:
      cut_short = f"stopped after {MAX_GREP_FILES} files"
      break
    if scanned_bytes >= MAX_GREP_TOTAL_BYTES:
      cut_short = f"stopped after scanning {human_size(scanned_bytes)}"
      break
    scanned += 1
    try:
      if file_path.stat().st_size > GREP_MAX_FILE_BYTES:
        skipped_big += 1
        continue
      data = file_path.read_bytes()
    except OSError:
      unreadable += 1
      continue
    scanned_bytes += len(data)
    if b"\0" in data[:8192]:
      skipped_binary += 1
      continue

    # finditer over the whole text, with line numbers derived from newline
    # counts, is several times faster than calling search() once per line -
    # and grep over a real source tree is a hot path for an agent.
    text = data.decode("utf-8", errors="replace")
    line = 1
    consumed = 0
    for found in regex.finditer(text):
      line += text.count("\n", consumed, found.start())
      consumed = found.start()
      start = text.rfind("\n", 0, found.start()) + 1
      end = text.find("\n", found.start())
      if end < 0:
        end = len(text)
      snippet = text[start:end].strip()[:GREP_MAX_LINE_CHARS]
      matches.append(f"{file_path}:{line}: {snippet}")
      if len(matches) >= max_results:
        break
    if len(matches) >= max_results:
      break

  notes = []
  if skipped_binary:
    notes.append(f"{skipped_binary} binary file(s) skipped")
  if skipped_big:
    notes.append(f"{skipped_big} file(s) over"
                 f" {human_size(GREP_MAX_FILE_BYTES)} skipped")
  if unreadable:
    notes.append(f"{unreadable} unreadable file(s) skipped")
  if len(matches) >= max_results:
    notes.append(f"stopped at {max_results} matches; narrow the search"
                 " with include=..., or raise max_results")
  if cut_short:
    # Said loudly, and first: the model must not read "no matches" as proof
    # that the thing does not exist when most of the tree was never opened.
    notes.insert(0, f"⚠️ SEARCH INCOMPLETE: {cut_short}")

  if not matches:
    summary = (f"No matches for {pattern!r} in {root}"
               f" ({scanned} file(s) searched)")
    if cut_short:
      summary += ", but the search was cut short - treat this as unknown,"
      summary += " not as absence."
    if notes:
      summary += "\n[" + "; ".join(notes) + "]"
    return summary

  body = "\n".join(matches)
  if notes:
    body += "\n[" + "; ".join(notes) + "]"
  return truncate_output(body, max_chars=8000)


# Tool execution logic
def execute_tool(name: str, arguments: dict) -> str:
  if name == "run_shell_command":
    cmd = arguments.get("command", "")
    normalised_cmd = " ".join(cmd.split())
    if normalised_cmd in CATASTROPHIC_COMMANDS:
      print(f"\n🚫 Refused a command on the do-not-run list: {normalised_cmd}")
      return (
          f"Error: {normalised_cmd!r} is on the do-not-run list, so it was not"
          " executed. Ask the user to run it by hand if it is really intended."
      )

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to execute shell"
        f" command:\n  > {cmd}\nAllow execution? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied command execution.")

    print(f"\n🚀 Executing: {cmd}\n" + "-" * 40)
    process = None
    try:
      # Popen for real-time streaming, no timeout to support long builds
      # (e.g. debuild). errors="replace" keeps a stray non-UTF-8 byte in a
      # build log from killing the read loop and leaving the child running.
      #
      # stdin must NOT be inherited. Without DEVNULL the child reads from the
      # same terminal the REPL uses, so `read`, `cat`, `less`, `ssh`,
      # `git commit` (editor) or a debconf prompt would swallow the user's
      # keystrokes and leave the terminal in a mess. With DEVNULL those
      # commands see EOF at once and fail instead of hanging.
      #
      # start_new_session does two things. The child gets its own process
      # group, so Ctrl-C can be handled here and turned into a SIGTERM for the
      # whole tree (see terminate_process_group) instead of being delivered to
      # the child behind our back. And it drops the controlling terminal, so
      # the child cannot write escape sequences to the user's screen either.
      process = subprocess.Popen(
          cmd,
          shell=True,
          stdin=subprocess.DEVNULL,
          stdout=subprocess.PIPE,
          stderr=subprocess.STDOUT,
          text=True,
          errors="replace",
          start_new_session=True,
      )

      output_lines = []
      try:
        for line in process.stdout:
          print(line, end="")
          output_lines.append(line)
      finally:
        # Release our end of the pipe even when Ctrl-C unwinds this loop, so
        # the doomed child gets EPIPE/SIGPIPE instead of blocking on a buffer
        # nobody drains.
        process.stdout.close()

      process.wait()
      print("-" * 40)

      full_output = "".join(output_lines)
      exit_status = describe_exit_status(process.returncode)
      print(f"↩️  {exit_status}")

      if not full_output.strip():
        # Empty output does not mean success: `false`, `test -f missing` and
        # `grep -q` all print nothing and can still fail.
        return f"Command produced no output.\n{exit_status}"

      # Smart truncation before sending back to LLM context. The status line is
      # appended afterwards so truncation can never cut it away.
      return f"{truncate_output(full_output, max_chars=8000)}\n{exit_status}"

    except KeyboardInterrupt:
      # Not terminate(): that only signals the `sh -c` wrapper. See
      # terminate_process_group.
      if process is not None:
        terminate_process_group(process)
      print("\n⚠️ Command interrupted by user (Ctrl + C).")
      raise ToolInterrupted() from None
    except Exception as e:
      # Same reasoning: an unexpected failure here must not leave the child
      # running with nobody reading its output.
      if process is not None:
        terminate_process_group(process)
      return f"Execution error: {str(e)}"

  elif name == "write_file":
    filepath = arguments.get("filepath", "")
    content = arguments.get("content", "")

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to write/modify"
        f" file:\n  > {filepath}\nAllow file write? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied file write.")

    try:
      with open(filepath, "w", encoding="utf-8") as f:
        f.write(content)
      return f"Successfully wrote content to {filepath}"
    except Exception as e:
      return f"Error writing to file: {str(e)}"

  elif name == "edit_file":
    filepath = arguments.get("filepath", "")
    old_string = arguments.get("old_string", "")
    new_string = arguments.get("new_string", "")
    replace_all = bool(arguments.get("replace_all", False))

    if not filepath:
      return "Error: 'filepath' is required."
    if old_string == "":
      return (
          "Error: 'old_string' must not be empty, give the exact text to"
          " replace."
      )
    if old_string == new_string:
      return "Error: 'old_string' and 'new_string' are identical, nothing to do."

    try:
      with open(filepath, "r", encoding="utf-8", newline="") as f:
        original = f.read()
    except FileNotFoundError:
      return f"Error: no such file: {filepath}"
    except IsADirectoryError:
      return f"Error: {filepath} is a directory, not a file."
    except UnicodeDecodeError:
      return f"Error: {filepath} is not valid UTF-8 text, refusing to edit it."
    except OSError as exc:
      return f"Error reading {filepath}: {exc}"

    positions = list(iter_occurrences(original, old_string))
    if not positions:
      return (
          f"Error: the text to replace was not found in {filepath}. Read the"
          " file again and copy the exact text, including indentation."
      )
    if len(positions) > 1 and not replace_all:
      line_numbers = [
          original.count("\n", 0, index) + 1 for index in positions
      ]
      shown_lines = ", ".join(str(n) for n in line_numbers)
      return (
          f"Error: the text occurs {len(positions)} times in {filepath}"
          f" (lines {shown_lines}). Add more surrounding context so it is"
          " unique, or pass replace_all=true."
      )

    if replace_all:
      updated = original.replace(old_string, new_string)
    else:
      updated = original.replace(old_string, new_string, 1)

    first_line = original.count("\n", 0, positions[0]) + 1
    replaced_count = len(positions) if replace_all else 1

    prompt = (
        f"\n⚠️ [Security Confirmation] Agent requests to edit file:"
        f"\n  > {filepath} (line {first_line},"
        f" {replaced_count} replacement(s))"
        f"\n    replace:\n{format_edit_preview(old_string, '-')}"
        f"\n    with:\n{format_edit_preview(new_string, '+')}"
        f"\nAllow this edit? (Y/N): "
    )
    if not get_user_confirmation(prompt):
      raise ToolDenied("User explicitly denied the file edit.")

    try:
      write_file_atomically(filepath, updated)
    except OSError as exc:
      return f"Error writing {filepath}: {exc}"

    if replaced_count == 1:
      where = f"at line {first_line}"
      quantity = "1 occurrence"
    else:
      where = f"from line {first_line}"
      quantity = f"{replaced_count} occurrences"
    return f"Successfully replaced {quantity} in {filepath} {where}."

  elif name == "list_dir":
    return tool_list_dir(arguments)

  elif name == "grep":
    return tool_grep(arguments)

  elif name == "read_file":
    filepath = arguments.get("filepath", "")
    start_line = arguments.get("start_line")
    end_line = arguments.get("end_line")

    try:
      with open(filepath, "r", encoding="utf-8") as f:
        lines = f.readlines()

      if start_line is not None or end_line is not None:
        start_idx = (start_line - 1) if (start_line is not None and start_line > 0) else 0
        end_idx = end_line if end_line is not None else len(lines)
        
        selected_lines = lines[start_idx:end_idx]
        actual_start = start_idx + 1
        actual_end = start_idx + len(selected_lines)
        
        header = f"[Lines {actual_start}-{actual_end} of {filepath} (total {len(lines)} lines)]\n"
        content = header + "".join(selected_lines)
        return truncate_output(content, max_chars=8000)
      else:
        return truncate_output("".join(lines), max_chars=8000)

    except Exception as e:
      return f"Error reading file: {str(e)}"

  elif name == "fetch_webpage":
    url = arguments.get("url", "")

    allowed, reason = check_url_allowed(url)
    if not allowed:
      print(f"\n🚫 Refused to fetch {url}: {reason}")
      return f"Error: refused to fetch '{url}' because {reason}."

    print(f"\n🌐 Fetching webpage: {url}\n" + "-" * 40)
    try:
      req = urllib.request.Request(
          url,
          headers={"User-Agent": "Mozilla/5.0 (compatible; CorindoCLI/1.0)"}
      )
      opener = urllib.request.build_opener(PublicOnlyRedirectHandler)
      with opener.open(req, timeout=15) as response:
        html_content = response.read().decode("utf-8", errors="ignore")

      class HTMLFilter(HTMLParser):
        def __init__(self):
          super().__init__()
          self.text = []
        def handle_data(self, data):
          self.text.append(data)

      parser = HTMLFilter()
      parser.feed(html_content)
      text_content = "".join(parser.text)
      cleaned_text = "\n".join([line.strip() for line in text_content.splitlines() if line.strip()])

      print("-" * 40)
      return truncate_output(cleaned_text, max_chars=8000)
    except Exception as e:
      return f"Error fetching webpage: {str(e)}"

  return f"Error: Unknown tool '{name}'"


# Session management helpers
def save_session(messages, filename):
  """Serializes and saves messages list to a JSON session file."""
  try:
    serializable_messages = []
    for m in messages:
      if hasattr(m, "model_dump"):
        serializable_messages.append(m.model_dump())
      elif hasattr(m, "dict"):
        serializable_messages.append(m.dict())
      elif isinstance(m, dict):
        serializable_messages.append(m)
      else:
        serializable_messages.append(dict(m))

    path = pathlib.Path(filename)
    with open(path, "w", encoding="utf-8") as f:
      json.dump(serializable_messages, f, ensure_ascii=False, indent=2)
    print(f"💾 Session successfully saved to {path}")
  except Exception as e:
    print(f"❌ Failed to save session: {e}")


def resume_session(filename):
  """Loads messages list from a JSON session file."""
  try:
    path = pathlib.Path(filename)
    if not path.exists():
      alt_path = pathlib.Path.home() / ".config" / "corindo-cli" / filename
      if alt_path.exists():
        path = alt_path
      else:
        print(f"❌ Session file not found: {filename}")
        return None

    with open(path, "r", encoding="utf-8") as f:
      data = json.load(f)
    print(f"📂 Session successfully loaded from {path}")
    return data
  except Exception as e:
    print(f"❌ Failed to resume session: {e}")
    return None


def print_help():
  """Prints available slash commands."""
  print("\n📖 Corindo CLI Help - Available Commands:")
  print("  /help                     Show this help message")
  print("  /save-session <filename>  Save current conversation session to file")
  print("  /resume <filename>        Resume a conversation session from file")
  print("  /exit, /quit              Exit the CLI agent\n")


# REPL Main Loop
def ensure_interruptible():
  """Re-installs the Ctrl-C handler if SIGINT was inherited as ignored.

  CPython deliberately leaves SIGINT alone when it starts up with SIGINT
  already set to SIG_IGN: that is how a program is normally launched when it
  is meant not to be interruptible from the terminal. An interactive agent is
  the opposite case, and it is commonly started through a wrapper that ignores
  SIGINT so that the *wrapper* survives Ctrl-C - see bwrap.sh, where a
  SIGINT-killed bwrap would tear down the whole sandbox (it is PID 1 of the
  new PID namespace) and take a half-finished turn with it.

  Without this, that trick makes Ctrl-C silently dead inside the agent: the
  keystroke reaches the terminal, nothing reacts, and the user is left with a
  command that cannot be stopped.
  """
  if signal.getsignal(signal.SIGINT) is signal.SIG_IGN:
    signal.signal(signal.SIGINT, signal.default_int_handler)
    print("ℹ️  SIGINT was inherited as ignored; Ctrl-C handling re-enabled.")


def main():
  ensure_interruptible()

  # Setup config and history directory under ~/.config/corindo-cli/
  config_dir = pathlib.Path.home() / ".config" / "corindo-cli"
  config_dir.mkdir(parents=True, exist_ok=True)
  history_file = config_dir / ".history"

  if history_file.exists():
    readline.read_history_file(history_file)

  print(f"🤖 Corindo CLI Agent started! Using model: {MODEL_NAME}")
  print(f"📁 Config loaded from: {loaded_path}")
  print("Type '/exit' or '/quit' to exit.")
  print("Type '/help' to view available commands.\n")

  messages = [
      {
          "role": "system",
          "content": (
              "You are a capable terminal and development assistant"
              " agent. You can read/write files, fetch web pages, and run shell commands (e.g.,"
              " debuild, git, cargo) to help the user complete software"
              " engineering tasks. Always reason clearly before taking"
              " actions."
          ),
      }
  ]

  while True:
    try:
      user_input = input("\n👉 You: ")
      if user_input.lower() in ["/exit", "/quit"]:
        break
      
      # Handle /help command
      if user_input.lower() == "/help":
        print_help()
        continue

      # Handle /save-session command
      if user_input.startswith("/save-session"):
        parts = user_input.split(maxsplit=1)
        if len(parts) < 2:
          print("❌ Usage: /save-session <filename>")
        else:
          filename = parts[1].strip()
          save_session(messages, filename)
        continue

      # Handle /resume command
      if user_input.startswith("/resume"):
        parts = user_input.split(maxsplit=1)
        if len(parts) < 2:
          print("❌ Usage: /resume <filename>")
        else:
          filename = parts[1].strip()
          loaded = resume_session(filename)
          if loaded is not None:
            messages = loaded
            print(f"✨ Resumed session with {len(messages)} messages loaded.")
        continue

      if not user_input.strip():
        continue

      messages.append({"role": "user", "content": user_input})

      # Ensure that the tool_denied state is reset at the start of
      # each new dialogue round.
      tool_denied = False

      response = call_llm(messages=messages, tools=tools, tool_choice="auto")

      response_message = response.choices[0].message
      messages.append(response_message)

      while response_message.tool_calls:
        tool_denied = False

        denied_in_batch = False
        interrupted_in_batch = False
        tool_interrupted = False

        for tool_call in response_message.tool_calls:
          function_name = tool_call.function.name

          # Every tool_call of this assistant message must be answered, even
          # the ones we never run: the API rejects the conversation with 400
          # if a tool_call is left without its matching tool message.
          if denied_in_batch or interrupted_in_batch:
            if denied_in_batch:
              skip_reason = (
                  "Skipped: the user denied an earlier tool call in this"
                  " batch."
              )
            else:
              skip_reason = (
                  "Skipped: the user interrupted an earlier command in this"
                  " batch."
              )
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": skip_reason,
            })
            continue

          try:
            function_args = json.loads(tool_call.function.arguments or "{}")
          except json.JSONDecodeError as exc:
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": (
                    f"Error: tool arguments are not valid JSON: {exc}"
                ),
            })
            continue

          if not isinstance(function_args, dict):
            # Valid JSON but not an object ("[1, 2]", '"text"', "null").
            # Catch it here so the model gets a readable complaint instead of
            # an AttributeError from the first arguments.get() deeper down.
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "name": function_name,
                "content": (
                    "Error: tool arguments must be a JSON object mapping"
                    " parameter names to values, got"
                    f" {type(function_args).__name__}."
                ),
            })
            continue

          print(
              f"⚙️  Calling tool: {function_name}("
              f"{json.dumps(function_args, ensure_ascii=False)})"
          )

          try:
            tool_output = execute_tool(function_name, function_args)
          except ToolDenied as exc:
            tool_output = f"Execution cancelled: {exc}"
            tool_denied = True
            denied_in_batch = True
          except ToolInterrupted:
            tool_output = (
                "Execution interrupted: the user stopped this command with"
                " Ctrl-C. This was not a denial."
            )
            tool_interrupted = True
            interrupted_in_batch = True
          except Exception as exc:  # noqa: BLE001
            # A tool bug must never escape this loop. If it does, the
            # tool_call below is never answered, and from then on every single
            # request fails with 400 ("must be followed by tool messages
            # responding to each tool_call_id") - the whole session is dead
            # and /save-session is the only way out. Turning the crash into a
            # tool result keeps the conversation usable and tells the model
            # what went wrong.
            print(f"\n❌ Tool {function_name} crashed:"
                  f" {type(exc).__name__}: {exc}")
            tool_output = (
                f"Error: the tool '{function_name}' crashed with"
                f" {type(exc).__name__}: {exc}. Nothing was completed; retry"
                " with different arguments or another tool."
            )

          messages.append({
              "role": "tool",
              "tool_call_id": tool_call.id,
              "name": function_name,
              "content": tool_output,
          })

        if tool_denied:
          response = call_llm(messages=messages)
          response_message = response.choices[0].message
          messages.append(response_message)

          # Ensure that the AI's response to refusals is printed.
          if response_message.content:
            print(f"\n🤖 Agent:\n{response_message.content}")

          break

        if tool_interrupted:
          # Being interrupted is not a refusal: keep the tools so the model can
          # react, for example retry with a narrower command. The user can
          # always interrupt again.
          response = call_llm(messages=messages, tools=tools, tool_choice="auto")
          response_message = response.choices[0].message
          messages.append(response_message)
          continue

        response = call_llm(messages=messages, tools=tools, tool_choice="auto")
        response_message = response.choices[0].message
        messages.append(response_message)

      if response_message.content and not tool_denied:
        print(f"\n🤖 Agent:\n{response_message.content}")

      readline.write_history_file(history_file)

    except KeyboardInterrupt:
      print("\nGoodbye!")
      break
    except Exception as e:
      print(f"\n❌ Error occurred: {e}")


if __name__ == "__main__":
  main()
