import os
import io
import re
import json
import stat
import base64
import shutil
import socket
import secrets
import tempfile
import subprocess
import webbrowser
import threading
import calendar
import time
import ftplib
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
HTML_PATH = os.path.join(SCRIPT_DIR, "app.html")
HOST = "127.0.0.1"


def pick_free_port():
    for _ in range(50):
        candidate = secrets.randbelow(16384) + 49152
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            try:
                s.bind((HOST, candidate))
                return candidate
            except OSError:
                continue
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind((HOST, 0))
        return s.getsockname()[1]

SFTP_BIN = shutil.which("sftp")
SSH_BIN = shutil.which("ssh")

import sys as _sys
MUX_SUPPORTED = not _sys.platform.startswith("win")
SESSION_IDLE_SECONDS = 600


class SftpError(Exception):
    pass


class _Session:
    def __init__(self, host, username, port, password, key_path, ctl_path, ctl_dir):
        self.host = host
        self.username = username
        self.port = port
        self.password = password
        self.key_path = key_path
        self.ctl_path = ctl_path
        self.ctl_dir = ctl_dir
        self.last_used = time.time()
        self.lock = threading.Lock()
        self.address_family = None


_SESSIONS = {}
_SESSIONS_LOCK = threading.Lock()

_PROBE_TIMEOUT = 6
_PROBE_CACHE = {}
_PROBE_CACHE_LOCK = threading.Lock()


def _probe_one(family, sockaddr, result, done_event):
    s = socket.socket(family, socket.SOCK_STREAM)
    s.settimeout(_PROBE_TIMEOUT)
    try:
        s.connect(sockaddr)
        with result["lock"]:
            if result["family"] is None:
                result["family"] = family
                done_event.set()
    except Exception:
        pass
    finally:
        try:
            s.close()
        except Exception:
            pass


def _fastest_family(host, port):
    cache_key = host + ":" + str(port)
    with _PROBE_CACHE_LOCK:
        cached = _PROBE_CACHE.get(cache_key)
    if cached is not None and time.time() - cached[1] < 120:
        return cached[0]

    try:
        infos = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM)
    except socket.gaierror as e:
        raise SftpError("Could not resolve host: " + host + " (" + str(e) + ")")

    seen = []
    for family, _stype, _proto, _canon, sockaddr in infos:
        if family in (socket.AF_INET, socket.AF_INET6):
            seen.append((family, sockaddr))

    if not seen:
        raise SftpError("No usable address found for " + host)

    result = {"family": None, "lock": threading.Lock()}
    done = threading.Event()
    threads = []
    for family, sockaddr in seen:
        t = threading.Thread(target=_probe_one,
                             args=(family, sockaddr, result, done), daemon=True)
        t.start()
        threads.append(t)

    done.wait(timeout=_PROBE_TIMEOUT + 1)
    fam = result["family"]

    if fam is None:
        raise SftpError("Could not reach " + host + " on port " + str(port) +
                        ". The port may be blocked or closed from this network.")

    with _PROBE_CACHE_LOCK:
        _PROBE_CACHE[cache_key] = (fam, time.time())
    return fam


def _family_opt(family):
    if family == socket.AF_INET:
        return ["-o", "AddressFamily=inet"]
    if family == socket.AF_INET6:
        return ["-o", "AddressFamily=inet6"]
    return []


def _session_key(host, username, port):
    return username + "@" + host + ":" + str(port)


def _target(payload):
    host = payload.get("host", "").strip()
    username = payload.get("username", "").strip()
    port = int(payload.get("port") or 22)
    password = payload.get("password", "") or ""
    if not host or not username:
        raise SftpError("Host and username are required")
    return host, username, port, password


def _open_master(session):
    if session.address_family is None:
        session.address_family = _fastest_family(session.host, session.port)
    base_args = [SSH_BIN,
            "-o", "StrictHostKeyChecking=accept-new",
            "-o", "ConnectTimeout=15",
            "-o", "GSSAPIAuthentication=no",
            "-o", "ServerAliveInterval=15",
            "-p", str(session.port)]
    base_args += _family_opt(session.address_family)
    base_args += ["-o", "ControlMaster=yes",
             "-o", "ControlPath=" + session.ctl_path,
             "-o", "ControlPersist=" + str(SESSION_IDLE_SECONDS)]

    # Try key auth first
    if session.key_path:
        args = list(base_args)
        args += ["-o", "BatchMode=yes",
                 "-o", "PreferredAuthentications=publickey",
                 "-o", "PubkeyAuthentication=yes",
                 "-o", "IdentitiesOnly=yes", "-i", session.key_path]
        args += ["-N", "-f", session.username + "@" + session.host]
        proc = subprocess.run(args, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT, timeout=30)
        if proc.returncode == 0:
            return
        # Key auth failed, try password if available
        if not session.password:
            raise SftpError(_clean_error(proc.stdout.decode("utf-8", "replace")))

    # Try password auth
    if session.password:
        args = list(base_args)
        args += ["-o", "PreferredAuthentications=password",
                 "-o", "PubkeyAuthentication=no"]
        args += ["-N", "-f", session.username + "@" + session.host]
        env = dict(os.environ)
        env["SSH_ASKPASS"] = "echo"
        env["DISPLAY"] = ":0"
        proc = subprocess.run(args, stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT, timeout=30,
                              input=(session.password + "\n").encode("utf-8"),
                              env=env)
        if proc.returncode != 0:
            raise SftpError(_clean_error(proc.stdout.decode("utf-8", "replace")))
    else:
        raise SftpError("Authentication failed. Provide a valid private key or password.")


def _master_alive(session):
    if not os.path.exists(session.ctl_path):
        return False
    args = [SSH_BIN, "-o", "ControlPath=" + session.ctl_path,
            "-O", "check", "-p", str(session.port),
            session.username + "@" + session.host]
    try:
        proc = subprocess.run(args, stdout=subprocess.DEVNULL,
                              stderr=subprocess.DEVNULL, timeout=10)
        return proc.returncode == 0
    except Exception:
        return False


def _get_session(payload):
    host, username, port, password = _target(payload)
    sk = _session_key(host, username, port)
    private_key = payload.get("privateKey", "")

    fresh = False
    with _SESSIONS_LOCK:
        session = _SESSIONS.get(sk)
        if session is None:
            ctl_dir = tempfile.mkdtemp(prefix="sftpmux_")
            os.chmod(ctl_dir, 0o700)
            key_path = None
            if private_key and private_key != "__HASHED__":
                fd, key_path = tempfile.mkstemp(prefix="sftpkey_", dir=ctl_dir)
                os.close(fd)
                os.chmod(key_path, 0o600)
                data = private_key if private_key.endswith("\n") else private_key + "\n"
                with open(key_path, "w", newline="\n") as f:
                    f.write(data)
            ctl_path = os.path.join(ctl_dir, "ctl.sock")
            session = _Session(host, username, port, password, key_path, ctl_path, ctl_dir)
            _SESSIONS[sk] = session
            fresh = True

    with session.lock:
        if fresh or not _master_alive(session):
            _open_master(session)
        session.last_used = time.time()
    return session


def _close_session(session):
    try:
        if os.path.exists(session.ctl_path):
            subprocess.run([SSH_BIN, "-o", "ControlPath=" + session.ctl_path,
                            "-O", "exit", "-p", str(session.port),
                            session.username + "@" + session.host],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                           timeout=10)
    except Exception:
        pass
    if session.key_path:
        _safe_unlink(session.key_path)
    shutil.rmtree(session.ctl_dir, ignore_errors=True)


def _reap_idle_sessions():
    while True:
        time.sleep(60)
        now = time.time()
        stale = []
        with _SESSIONS_LOCK:
            for sk, s in list(_SESSIONS.items()):
                if now - s.last_used > SESSION_IDLE_SECONDS:
                    stale.append(s)
                    del _SESSIONS[sk]
        for s in stale:
            _close_session(s)


def _close_all_sessions():
    with _SESSIONS_LOCK:
        items = list(_SESSIONS.values())
        _SESSIONS.clear()
    for s in items:
        _close_session(s)


def _write_key(private_key):
    if not private_key or private_key == "__HASHED__" or not private_key.strip():
        return None
    fd, path = tempfile.mkstemp(prefix="sftpkey_")
    os.close(fd)
    os.chmod(path, 0o600)
    data = private_key if private_key.endswith("\n") else private_key + "\n"
    with open(path, "w", newline="\n") as f:
        f.write(data)
    return path


def _exec_sftp(host, username, port, extra_opts, batch_cmds, password=None):
    args = [SFTP_BIN,
            "-o", "StrictHostKeyChecking=accept-new",
            "-o", "ConnectTimeout=15",
            "-o", "GSSAPIAuthentication=no",
            "-P", str(port)]
    args += extra_opts
    args += ["-b", "-", username + "@" + host]
    script = "\n".join(batch_cmds) + "\n"
    env = dict(os.environ)
    if password:
        env["SSH_ASKPASS"] = "echo"
        env["DISPLAY"] = ":0"
    proc = subprocess.run(
        args,
        input=script.encode("utf-8"),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        timeout=120,
        env=env,
    )
    out = proc.stdout.decode("utf-8", "replace")
    if proc.returncode != 0:
        raise SftpError(_clean_error(out))
    return out


def _run_sftp(payload, batch_cmds, key_path):
    if not SFTP_BIN:
        raise SftpError("The 'sftp' command was not found. Install OpenSSH "
                        "(it ships with macOS, Linux, and Windows 10+).")
    host, username, port, password = _target(payload)

    if MUX_SUPPORTED and SSH_BIN:
        session = _get_session(payload)
        with session.lock:
            session.last_used = time.time()
            opts = ["-o", "ControlMaster=no",
                    "-o", "ControlPath=" + session.ctl_path]
            try:
                return _exec_sftp(host, username, port, opts, batch_cmds, session.password)
            except SftpError:
                if not _master_alive(session):
                    _open_master(session)
                    return _exec_sftp(host, username, port, opts, batch_cmds, session.password)
                raise

    opts = []
    if key_path:
        opts += ["-o", "IdentitiesOnly=yes", "-i", key_path]
    elif password:
        opts += ["-o", "PreferredAuthentications=password",
                 "-o", "PubkeyAuthentication=no"]
    try:
        fam = _fastest_family(host, port)
        opts += _family_opt(fam)
    except SftpError:
        pass
    return _exec_sftp(host, username, port, opts, batch_cmds, password)


def _exec_ssh(host, username, port, extra_opts, command, password=None):
    args = [SSH_BIN,
            "-o", "StrictHostKeyChecking=accept-new",
            "-o", "ConnectTimeout=15",
            "-o", "GSSAPIAuthentication=no",
            "-p", str(port)]
    args += extra_opts
    args += [username + "@" + host, command]
    env = dict(os.environ)
    if password:
        env["SSH_ASKPASS"] = "echo"
        env["DISPLAY"] = ":0"
    proc = subprocess.run(
        args,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        timeout=300,
        env=env,
    )
    out = proc.stdout.decode("utf-8", "replace")
    if proc.returncode != 0:
        raise SftpError(_clean_error(out) or "Command failed")
    return out


def _run_ssh(payload, command, key_path):
    if not SSH_BIN:
        raise SftpError("The 'ssh' command was not found. Install OpenSSH.")
    host, username, port, password = _target(payload)

    if MUX_SUPPORTED and SSH_BIN:
        session = _get_session(payload)
        with session.lock:
            session.last_used = time.time()
            opts = ["-o", "ControlMaster=no",
                    "-o", "ControlPath=" + session.ctl_path]
            try:
                return _exec_ssh(host, username, port, opts, command, session.password)
            except SftpError:
                if not _master_alive(session):
                    _open_master(session)
                    return _exec_ssh(host, username, port, opts, command, session.password)
                raise

    opts = []
    if key_path:
        opts += ["-o", "IdentitiesOnly=yes", "-i", key_path]
    elif password:
        opts += ["-o", "PreferredAuthentications=password",
                 "-o", "PubkeyAuthentication=no"]
    try:
        fam = _fastest_family(host, port)
        opts += _family_opt(fam)
    except SftpError:
        pass
    return _exec_ssh(host, username, port, opts, command, password)


def _shq(path):
    return "'" + path.replace("'", "'\\''") + "'"


def _clean_error(out):
    lines = [l.strip() for l in out.splitlines() if l.strip()]
    for l in lines:
        low = l.lower()
        if ("permission denied" in low or "denied" in low or "not found" in low
                or "no such file" in low or "failure" in low or "cannot" in low
                or "error" in low or "refused" in low or "timed out" in low
                or "could not" in low):
            return re.sub(r"^sftp>\s*", "", l)
    return re.sub(r"^sftp>\s*", "", lines[-1]) if lines else "SFTP command failed"


_MONTHS = {m: i for i, m in enumerate(
    ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
     "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], start=1)}


def _parse_ls_line(line):
    if not line or line.startswith("total "):
        return None
    parts = line.split()
    if len(parts) < 9:
        return None
    perms = parts[0]
    if perms[0] not in "-dlbcps":
        return None

    is_dir = perms[0] == "d"
    is_link = perms[0] == "l"
    try:
        size = int(parts[4])
    except ValueError:
        size = 0

    mon, day, timeyear = parts[5], parts[6], parts[7]
    name_idx = 8
    if is_link and "->" in parts:
        arrow = parts.index("->")
        name = " ".join(parts[name_idx:arrow])
    else:
        name = " ".join(parts[name_idx:])

    if name in (".", ".."):
        return None

    modified = _parse_ls_time(mon, day, timeyear)
    return {
        "name": name,
        "size": size,
        "isDirectory": is_dir,
        "modified": modified,
        "permissions": perms,
    }


def _parse_ls_time(mon, day, timeyear):
    try:
        month = _MONTHS.get(mon)
        if not month:
            return 0
        day = int(day)
        now = datetime.now()
        if ":" in timeyear:
            hh, mm = timeyear.split(":")
            year = now.year
            dt = datetime(year, month, day, int(hh), int(mm))
            if dt.timestamp() - now.timestamp() > 86400 * 30:
                dt = datetime(year - 1, month, day, int(hh), int(mm))
        else:
            dt = datetime(int(timeyear), month, day)
        return int(dt.timestamp() * 1000)
    except Exception:
        return 0


def _quote(path):
    return '"' + path.replace('"', '\\"') + '"'


# ---------------------------------------------------------------------------
# FTP support (plain FTP and FTP over TLS) using the Python standard library.
# No external packages required. FTP is unencrypted by default and far less
# secure than SFTP, but plenty of legacy hosts only speak it.
# ---------------------------------------------------------------------------

FTP_IDLE_SECONDS = 300


class _FtpSession:
    def __init__(self, conn, host, username, port, secure):
        self.conn = conn
        self.host = host
        self.username = username
        self.port = port
        self.secure = secure
        self.last_used = time.time()
        self.lock = threading.Lock()


_FTP_SESSIONS = {}
_FTP_SESSIONS_LOCK = threading.Lock()


def _ftp_target(payload):
    host = payload.get("host", "").strip()
    username = payload.get("username", "").strip()
    port = int(payload.get("port") or 21)
    password = payload.get("password", "") or ""
    secure = bool(payload.get("ftpSecure"))
    if not host:
        raise SftpError("Host is required")
    if not username:
        username = "anonymous"
    return host, username, port, password, secure


def _ftp_session_key(host, username, port, secure):
    return ("ftps" if secure else "ftp") + ":" + username + "@" + host + ":" + str(port)


def _ftp_connect(host, username, port, password, secure):
    try:
        if secure:
            conn = ftplib.FTP_TLS()
            conn.connect(host, port, timeout=20)
            conn.login(username, password)
            conn.prot_p()
        else:
            conn = ftplib.FTP()
            conn.connect(host, port, timeout=20)
            conn.login(username, password)
        try:
            conn.set_pasv(True)
        except Exception:
            pass
        return conn
    except ftplib.error_perm as e:
        raise SftpError(_ftp_clean_error(str(e)) or "Login failed. Check the username and password.")
    except socket.gaierror as e:
        raise SftpError("Could not resolve host: " + host + " (" + str(e) + ")")
    except (socket.timeout, OSError) as e:
        raise SftpError("Could not reach " + host + " on port " + str(port) +
                        ". The port may be blocked or the server unreachable. (" + str(e) + ")")
    except Exception as e:
        raise SftpError(_ftp_clean_error(str(e)) or "FTP connection failed")


def _ftp_clean_error(msg):
    msg = (msg or "").strip()
    msg = re.sub(r"^\d{3}[\s-]*", "", msg)
    return msg


def _ftp_alive(session):
    try:
        session.conn.voidcmd("NOOP")
        return True
    except Exception:
        return False


def _get_ftp_session(payload):
    host, username, port, password, secure = _ftp_target(payload)
    sk = _ftp_session_key(host, username, port, secure)
    with _FTP_SESSIONS_LOCK:
        session = _FTP_SESSIONS.get(sk)
    if session is not None:
        with session.lock:
            if _ftp_alive(session):
                session.last_used = time.time()
                return session
        with _FTP_SESSIONS_LOCK:
            _FTP_SESSIONS.pop(sk, None)
        _close_ftp_session(session)

    conn = _ftp_connect(host, username, port, password, secure)
    session = _FtpSession(conn, host, username, port, secure)
    with _FTP_SESSIONS_LOCK:
        _FTP_SESSIONS[sk] = session
    return session


def _close_ftp_session(session):
    try:
        session.conn.quit()
    except Exception:
        try:
            session.conn.close()
        except Exception:
            pass


def _close_all_ftp_sessions():
    with _FTP_SESSIONS_LOCK:
        items = list(_FTP_SESSIONS.values())
        _FTP_SESSIONS.clear()
    for s in items:
        _close_ftp_session(s)


def _reap_idle_ftp_sessions():
    while True:
        time.sleep(60)
        now = time.time()
        stale = []
        with _FTP_SESSIONS_LOCK:
            for sk, s in list(_FTP_SESSIONS.items()):
                if now - s.last_used > FTP_IDLE_SECONDS:
                    stale.append(s)
                    del _FTP_SESSIONS[sk]
        for s in stale:
            _close_ftp_session(s)


def _ftp_norm(path):
    if not path:
        return "/"
    if not path.startswith("/"):
        path = "/" + path
    path = re.sub(r"/+", "/", path)
    if len(path) > 1:
        path = path.rstrip("/")
    return path or "/"


def _ftp_parse_mlsd(name, facts):
    typ = facts.get("type", "")
    if typ in ("cdir", "pdir") or name in (".", ".."):
        return None
    is_dir = typ == "dir"
    try:
        size = int(facts.get("size", 0))
    except (ValueError, TypeError):
        size = 0
    modified = 0
    modify = facts.get("modify")
    if modify and len(modify) >= 14:
        try:
            dt = datetime(int(modify[0:4]), int(modify[4:6]), int(modify[6:8]),
                          int(modify[8:10]), int(modify[10:12]), int(modify[12:14]))
            modified = int(dt.timestamp() * 1000)
        except Exception:
            modified = 0
    perms = _ftp_unix_perm_from_facts(facts, is_dir)
    return {
        "name": name,
        "size": 0 if is_dir else size,
        "isDirectory": is_dir,
        "modified": modified,
        "permissions": perms,
    }


def _ftp_unix_perm_from_facts(facts, is_dir):
    mode = facts.get("unix.mode") or facts.get("unix.modefact")
    if mode:
        try:
            m = int(mode, 8) if not str(mode).startswith("0o") else int(mode, 0)
            return _octal_to_rwx(m, is_dir)
        except Exception:
            pass
    return ("d" if is_dir else "-") + ("rwxr-xr-x" if is_dir else "rw-r--r--")


def _octal_to_rwx(mode, is_dir):
    bits = ["r", "w", "x"]
    s = "d" if is_dir else "-"
    for shift in (6, 3, 0):
        triad = (mode >> shift) & 0o7
        for i, b in enumerate(bits):
            s += b if triad & (4 >> i) else "-"
    return s


def _ftp_parse_list_line(line):
    return _parse_ls_line(line)


def _ftp_list(session, path):
    path = _ftp_norm(path)
    with session.lock:
        session.last_used = time.time()
        session.conn.cwd(path)
        files = []
        used_mlsd = False
        try:
            for name, facts in session.conn.mlsd():
                used_mlsd = True
                entry = _ftp_parse_mlsd(name, facts)
                if entry:
                    files.append(entry)
        except (ftplib.error_perm, ftplib.error_proto, AttributeError):
            used_mlsd = False
        except Exception:
            used_mlsd = False

        if not used_mlsd:
            lines = []
            try:
                session.conn.retrlines("LIST -a", lines.append)
            except Exception:
                lines = []
                session.conn.retrlines("LIST", lines.append)
            for raw in lines:
                entry = _ftp_parse_list_line(raw.strip())
                if entry:
                    files.append(entry)
    files.sort(key=lambda f: (not f["isDirectory"], f["name"].lower()))
    return path, files


def _ftp_read(session, path):
    buf = io.BytesIO()
    with session.lock:
        session.last_used = time.time()
        session.conn.retrbinary("RETR " + path, buf.write)
    return buf.getvalue()


def _ftp_write(session, path, data):
    bio = io.BytesIO(data)
    with session.lock:
        session.last_used = time.time()
        remote_dir = os.path.dirname(path)
        if remote_dir and remote_dir not in ("/", "."):
            _ftp_mkdirs(session.conn, remote_dir)
        session.conn.storbinary("STOR " + path, bio)


def _ftp_mkdirs(conn, path):
    parts = [p for p in _ftp_norm(path).strip("/").split("/") if p]
    cur = ""
    for part in parts:
        cur = cur + "/" + part
        try:
            conn.mkd(cur)
        except ftplib.error_perm:
            pass


def _ftp_delete(session, path, is_dir):
    with session.lock:
        session.last_used = time.time()
        if is_dir:
            _ftp_rmdir_recursive(session.conn, _ftp_norm(path))
        else:
            session.conn.delete(path)


def _ftp_rmdir_recursive(conn, path):
    try:
        entries = list(conn.mlsd(path))
    except Exception:
        entries = None
    if entries is None:
        for n in conn.nlst(path):
            base = os.path.basename(n.rstrip("/"))
            if base in (".", ".."):
                continue
            child = path.rstrip("/") + "/" + base
            try:
                conn.delete(child)
            except ftplib.error_perm:
                _ftp_rmdir_recursive(conn, child)
        conn.rmd(path)
        return
    for name, facts in entries:
        if name in (".", "..") or facts.get("type") in ("cdir", "pdir"):
            continue
        child = path.rstrip("/") + "/" + name
        if facts.get("type") == "dir":
            _ftp_rmdir_recursive(conn, child)
        else:
            conn.delete(child)
    conn.rmd(path)


def _ftp_chmod(session, path, mode):
    with session.lock:
        session.last_used = time.time()
        try:
            session.conn.voidcmd("SITE CHMOD " + str(mode) + " " + path)
        except ftplib.all_errors as e:
            raise SftpError("This FTP server does not support changing permissions "
                            "(SITE CHMOD): " + _ftp_clean_error(str(e)))


def handle_ftp_action(payload):
    action = payload.get("action", "")

    if action == "disconnect":
        host, username, port, password, secure = _ftp_target(payload)
        sk = _ftp_session_key(host, username, port, secure)
        with _FTP_SESSIONS_LOCK:
            s = _FTP_SESSIONS.pop(sk, None)
        if s:
            _close_ftp_session(s)
        return {"action": "disconnected"}

    if action == "connect":
        _get_ftp_session(payload)
        return {"action": "connected"}

    if action == "exec_check":
        return {"action": "exec_check", "shell": False, "reason": "ftp has no shell"}

    session = _get_ftp_session(payload)

    if action == "list":
        path, files = _ftp_list(session, payload.get("path", "/") or "/")
        return {"action": "listed", "path": path, "files": files}

    if action == "mkdir":
        with session.lock:
            session.conn.mkd(_ftp_norm(payload.get("path", "")))
        return {"action": "mkdir_done"}

    if action == "delete":
        path = payload.get("path", "")
        is_dir = bool(payload.get("isDirectory"))
        if not is_dir:
            try:
                with session.lock:
                    session.conn.delete(path)
                return {"action": "deleted"}
            except ftplib.all_errors:
                is_dir = True
        _ftp_delete(session, path, is_dir)
        return {"action": "deleted"}

    if action == "rename":
        old = payload.get("oldPath", "")
        new = payload.get("newPath", "")
        with session.lock:
            session.conn.rename(old, new)
        return {"action": "renamed"}

    if action == "read":
        data = _ftp_read(session, payload.get("path", ""))
        return {"action": "data",
                "chunk": base64.b64encode(data).decode("ascii"),
                "done": True}

    if action == "write":
        path = payload.get("path", "")
        data = base64.b64decode(payload.get("data", "") or "")
        _ftp_write(session, path, data)
        return {"action": "written"}

    if action == "chmod":
        _ftp_chmod(session, payload.get("path", ""), payload.get("mode", ""))
        return {"action": "chmod_done"}

    if action in ("diagnose", "fix_403", "ssh_exec", "ssh_copy_id"):
        raise SftpError("This feature requires an SSH connection and is not "
                        "available over FTP.")

    return {"action": "error", "message": "Unknown action: " + action}


def handle_action(payload):
    action = payload.get("action", "")
    if action == "__ping__":
        return {"action": "pong"}
    if (payload.get("protocol") or "sftp").lower() == "ftp":
        try:
            return handle_ftp_action(payload)
        except SftpError:
            raise
        except ftplib.all_errors as e:
            raise SftpError(_ftp_clean_error(str(e)) or "FTP operation failed")
    use_mux = MUX_SUPPORTED and SSH_BIN and SFTP_BIN
    key_path = None
    try:
        if action == "disconnect":
            if use_mux:
                host, username, port, password = _target(payload)
                sk = _session_key(host, username, port)
                with _SESSIONS_LOCK:
                    s = _SESSIONS.pop(sk, None)
                if s:
                    _close_session(s)
            return {"action": "disconnected"}

        if not use_mux:
            key_path = _write_key(payload.get("privateKey", ""))

        if action == "connect":
            if use_mux:
                _get_session(payload)
            else:
                _run_sftp(payload, ["pwd"], key_path)
            return {"action": "connected"}

        if action == "list":
            path = payload.get("path", "/") or "/"
            out = _run_sftp(payload, ["cd " + _quote(path), "ls -la"], key_path)
            files = []
            for line in out.splitlines():
                line = line.strip()
                if line.startswith("sftp>"):
                    continue
                entry = _parse_ls_line(line)
                if entry:
                    files.append(entry)
            files.sort(key=lambda f: (not f["isDirectory"], f["name"].lower()))
            return {"action": "listed", "path": path, "files": files}

        if action == "mkdir":
            _run_sftp(payload, ["mkdir " + _quote(payload.get("path", ""))], key_path)
            return {"action": "mkdir_done"}

        if action == "mkdir_p":
            path = payload.get("path", "")
            parts = [p for p in path.strip("/").split("/") if p]
            cmds, cur = [], ""
            for part in parts:
                cur = cur + "/" + part
                cmds.append("-mkdir " + _quote(cur))
            _run_sftp(payload, cmds or ["pwd"], key_path)
            return {"action": "mkdir_done"}

        if action == "delete":
            path = payload.get("path", "")
            try:
                _run_sftp(payload, ["rm " + _quote(path)], key_path)
                return {"action": "deleted"}
            except SftpError:
                _delete_recursive(payload, path, key_path)
                return {"action": "deleted"}

        if action == "rename":
            old = payload.get("oldPath", "")
            new = payload.get("newPath", "")
            _run_sftp(payload, ["rename " + _quote(old) + " " + _quote(new)], key_path)
            return {"action": "renamed"}

        if action == "read":
            path = payload.get("path", "")
            fd, tmp = tempfile.mkstemp(prefix="sftpget_")
            os.close(fd)
            try:
                _run_sftp(payload, ["get " + _quote(path) + " " + _quote(tmp)], key_path)
                with open(tmp, "rb") as f:
                    data = f.read()
            finally:
                _safe_unlink(tmp)
            return {"action": "data",
                    "chunk": base64.b64encode(data).decode("ascii"),
                    "done": True}

        if action == "write":
            path = payload.get("path", "")
            data = base64.b64decode(payload.get("data", "") or "")
            remote_dir = os.path.dirname(path)
            fd, tmp = tempfile.mkstemp(prefix="sftpput_")
            os.close(fd)
            try:
                with open(tmp, "wb") as f:
                    f.write(data)
                cmds = []
                if remote_dir and remote_dir not in ("/", "."):
                    parts = [p for p in remote_dir.strip("/").split("/") if p]
                    cur = ""
                    for part in parts:
                        cur = cur + "/" + part
                        cmds.append("-mkdir " + _quote(cur))
                cmds.append("put " + _quote(tmp) + " " + _quote(path))
                _run_sftp(payload, cmds, key_path)
            finally:
                _safe_unlink(tmp)
            try:
                ext = os.path.splitext(path)[1].lower()
                if ext in (".html", ".htm", ".php", ".css", ".js", ".json", ".xml",
                           ".txt", ".md", ".py", ".sh", ".yaml", ".yml", ".ini",
                           ".cfg", ".conf", ".env", ".svg"):
                    _run_ssh(payload, "chmod 644 " + _shq(path), key_path)
                elif not ext:
                    pass
                else:
                    _run_ssh(payload, "chmod 644 " + _shq(path), key_path)
            except SftpError:
                pass
            return {"action": "written"}

        if action == "move":
            old = payload.get("oldPath", "")
            new = payload.get("newPath", "")
            _run_sftp(payload, ["rename " + _quote(old) + " " + _quote(new)], key_path)
            return {"action": "moved"}

        if action == "chmod":
            path = payload.get("path", "")
            mode = payload.get("mode", "")
            _run_ssh(payload, "chmod " + _shq(mode) + " " + _shq(path), key_path)
            return {"action": "chmod_done"}

        if action == "exec_check":
            if not SSH_BIN:
                return {"action": "exec_check", "shell": False, "reason": "ssh not found"}
            try:
                out = _run_ssh(payload, "echo __ok__", key_path)
                ok = "__ok__" in out
            except SftpError as e:
                return {"action": "exec_check", "shell": False, "reason": str(e)}
            return {"action": "exec_check", "shell": ok}

        if action == "ssh_exec":
            command = payload.get("command", "").strip()
            if not command:
                raise SftpError("No command provided")
            out = _run_ssh(payload, command, key_path)
            return {"action": "ssh_exec_done", "output": out}

        if action == "ssh_copy_id":
            dest_path = payload.get("dest", "~/.ssh/authorized_keys").strip() or "~/.ssh/authorized_keys"
            pub_key = payload.get("pubKey", "").strip()
            if not pub_key:
                raise SftpError("No public key provided")
            cmd = ("mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
                   "echo " + _shq(pub_key) + " >> " + _shq(dest_path) + " && "
                   "chmod 600 " + _shq(dest_path))
            out = _run_ssh(payload, cmd, key_path)
            return {"action": "ssh_copy_id_done", "output": out}

        if action == "unzip":
            path = payload.get("path", "")
            dest = payload.get("dest", "")
            target_dir = dest if dest else os.path.dirname(path)
            if not target_dir or target_dir == ".":
                target_dir = "."
            cmd = ("mkdir -p " + _shq(target_dir) +
                   " && unzip -o " + _shq(path) + " -d " + _shq(target_dir))
            out = _run_ssh(payload, cmd, key_path)
            return {"action": "unzipped", "output": out[-2000:]}

        if action == "zip":
            paths = payload.get("paths") or []
            if not paths:
                single = payload.get("path", "")
                if single:
                    paths = [single]
            if not paths:
                raise SftpError("No files selected to compress")
            dest = payload.get("dest", "")
            if not dest:
                base_dir = os.path.dirname(paths[0]) or "."
                dest = (base_dir.rstrip("/") + "/archive.zip") if base_dir != "." else "archive.zip"
            base_dir = os.path.dirname(paths[0]) or "."
            names = [os.path.basename(p) for p in paths]
            quoted_names = " ".join(_shq(n) for n in names)
            cmd = ("cd " + _shq(base_dir) +
                   " && zip -r " + _shq(os.path.basename(dest)) + " " + quoted_names)
            out = _run_ssh(payload, cmd, key_path)
            return {"action": "zipped", "dest": dest, "output": out[-2000:]}

        if action == "diagnose":
            out = _run_ssh(payload, "echo __ok__", key_path)
            if "__ok__" not in out:
                raise SftpError("SSH exec check failed")
            cmds = [
                "echo '--- WEB SERVER ---'",
                "which nginx || which openresty || echo 'nginx/openresty not found'",
                "ps aux | grep -E 'nginx|php-fpm|php' | grep -v grep || echo 'no web processes running'",
                "echo '--- PHP ---'",
                "php -v 2>/dev/null || echo 'php cli not found'",
                "echo '--- DOCUMENT ROOT ---'",
                "ls -la /var/www/html 2>/dev/null || ls -la /usr/share/nginx/html 2>/dev/null || ls -la /home/*/www 2>/dev/null || echo 'common doc roots not found'",
                "echo '--- NGINX CONF ---'",
                "cat /etc/nginx/nginx.conf 2>/dev/null | head -40 || cat /usr/local/openresty/nginx/conf/nginx.conf 2>/dev/null | head -40 || echo 'nginx.conf not found'",
                "echo '--- VHOSTS ---'",
                "ls /etc/nginx/sites-enabled/ 2>/dev/null || ls /etc/nginx/conf.d/ 2>/dev/null || echo 'no vhost dirs'",
                "echo '--- INDEX FILES ---'",
                "find /var/www -maxdepth 3 -name 'index.*' 2>/dev/null | head -10 || echo 'no index files found'",
            ]
            diag = _run_ssh(payload, "; ".join(cmds), key_path)
            return {"action": "diagnosed", "output": diag}

        if action == "fix_403":
            doc_root = payload.get("docRoot", "/var/www/html").strip()
            domain = payload.get("host", "").strip()
            conf_path = "/etc/nginx/sites-enabled/" + domain
            conf = """server {
    listen 80;
    server_name """ + domain + """ www.""" + domain + """;
    root """ + doc_root + """;
    index index.html index.htm;

    location / {
        try_files $uri $uri/ =404;
    }

    location ~ /\\.ht {
        deny all;
    }
}"""
            _run_ssh(payload, "echo " + _shq(conf) + " > " + _shq(conf_path), key_path)
            _run_ssh(payload, "nginx -t 2>/dev/null || openresty -t 2>/dev/null || echo 'config test skipped'", key_path)
            _run_ssh(payload, "nginx -s reload 2>/dev/null || openresty -s reload 2>/dev/null || systemctl restart nginx 2>/dev/null || systemctl restart openresty 2>/dev/null || echo 'reload attempted'", key_path)
            return {"action": "fixed_403", "configPath": conf_path, "domain": domain}

        return {"action": "error", "message": "Unknown action: " + action}

    finally:
        if key_path:
            _safe_unlink(key_path)


def _delete_recursive(payload, path, key_path):
    cmds = []
    _build_delete_cmds(payload, path, key_path, cmds)
    if cmds:
        _run_sftp(payload, cmds, key_path)


def _build_delete_cmds(payload, path, key_path, cmds):
    out = _run_sftp(payload, ["cd " + _quote(path), "ls -la"], key_path)
    for line in out.splitlines():
        line = line.strip()
        if line.startswith("sftp>"):
            continue
        entry = _parse_ls_line(line)
        if not entry:
            continue
        child_path = path.rstrip("/") + "/" + entry["name"]
        if entry["isDirectory"]:
            _build_delete_cmds(payload, child_path, key_path, cmds)
        else:
            cmds.append("rm " + _quote(child_path))
    cmds.append("rmdir " + _quote(path))


def _safe_unlink(p):
    try:
        os.unlink(p)
    except OSError:
        pass


CONTENT_TYPES = {
    ".html": "text/html", ".css": "text/css",
    ".js": "application/javascript", ".png": "image/png",
    ".jpg": "image/jpeg", ".svg": "image/svg+xml",
    ".json": "application/json",
}


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        pass

    def _json(self, obj, code=200):
        body = json.dumps(obj).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Access-Control-Allow-Origin", "*")
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_GET(self):
        path = self.path.split("?", 1)[0]
        if path == "/":
            path = "/app.html"
        rel = path.lstrip("/")
        target = os.path.normpath(os.path.join(SCRIPT_DIR, rel))
        if not target.startswith(SCRIPT_DIR) or not os.path.isfile(target):
            self.send_response(404)
            self.send_header("Content-Length", "0")
            self.end_headers()
            return
        ext = os.path.splitext(target)[1].lower()
        ctype = CONTENT_TYPES.get(ext, "application/octet-stream")
        with open(target, "rb") as f:
            body = f.read()
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        try:
            length = int(self.headers.get("Content-Length", 0))
            raw = self.rfile.read(length) if length else b""
            payload = json.loads(raw.decode("utf-8")) if raw else {}
        except Exception:
            self._json({"action": "error", "message": "Invalid JSON"}, 400)
            return
        try:
            self._json(handle_action(payload))
        except SftpError as e:
            self._json({"action": "error", "message": str(e)})
        except subprocess.TimeoutExpired:
            self._json({"action": "error", "message": "Operation timed out"})
        except Exception as e:
            self._json({"action": "error", "message": str(e)})


def main():
    if not os.path.isfile(HTML_PATH):
        print("app.html not found next to script. Place it in: " + SCRIPT_DIR)
        return
    if not SFTP_BIN:
        print("WARNING: 'sftp' command not found on PATH.")
        print("Install OpenSSH client (built into macOS, Linux, Windows 10+).")
        print("SFTP connections will fail until it's available, but FTP will "
              "still work.\n")

    port = pick_free_port()
    url = "http://%s:%d/" % (HOST, port)
    server = ThreadingHTTPServer((HOST, port), Handler)
    print("SFTP / FTP bridge running on " + url)
    print("SFTP uses the system OpenSSH 'sftp' client; FTP uses Python's built-in")
    print("ftplib. No third-party Python packages required.")
    if MUX_SUPPORTED and SSH_BIN:
        print("SFTP connection reuse is on: the first connect is normal speed, "
              "everything after is fast.")
    print("This window must stay open while you use the tool.")
    print("Press Ctrl+C to stop")

    reaper = threading.Thread(target=_reap_idle_sessions, daemon=True)
    reaper.start()
    ftp_reaper = threading.Thread(target=_reap_idle_ftp_sessions, daemon=True)
    ftp_reaper.start()

    threading.Timer(0.6, lambda: webbrowser.open(url)).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nShutting down.")
    finally:
        _close_all_sessions()
        _close_all_ftp_sessions()
        server.shutdown()


if __name__ == "__main__":
    main()