import grp
import os
import pwd
import re
import shutil
import stat
import subprocess
import time
import zipfile
from pathlib import Path

MAX_EDITOR_BYTES = 1024 * 1024
MAX_SEARCH_RESULTS = 500
MAX_SEARCH_FILE_BYTES = 32 * 1024 * 1024
NAME_RE = re.compile(r"^[^/\\\x00]{1,255}$")

class FileManagerError(RuntimeError):
    pass

def _root(root):
    p = Path(root).expanduser()
    if not p.is_absolute():
        raise FileManagerError("Filesystem root must be absolute.")
    p = Path(os.path.abspath(str(p)))
    if not p.exists() or not p.is_dir():
        raise FileManagerError("Hosting home directory does not exist.")
    return p

def _rel(path):
    value = (path or ".").replace("\\", "/").strip()
    if value in ("", "/", "."):
        return "."
    while value.startswith("/"):
        value = value[1:]
    return value or "."

def _inside(root, candidate):
    root_s = os.path.realpath(str(root))
    cand_s = os.path.realpath(str(candidate))
    try:
        common = os.path.commonpath([root_s, cand_s])
    except ValueError:
        return False
    return common == root_s

def safe_path(root, relative=".", *, allow_missing=False, allow_root=True):
    root = _root(root)
    rel = _rel(str(relative))
    if rel == ".":
        p = root
    else:
        # Do not accept an absolute path disguised by repeated separators or traversal.
        parts = [x for x in rel.split("/") if x not in ("", ".")]
        if any(x == ".." for x in parts):
            raise FileManagerError("Path traversal is not allowed.")
        p = root.joinpath(*parts)
    if not allow_root and os.path.abspath(str(p)) == os.path.abspath(str(root)):
        raise FileManagerError("The hosting root cannot be modified by this operation.")
    if p.exists() or p.is_symlink():
        if not _inside(root, p):
            raise FileManagerError("Access outside the hosting root is blocked.")
    elif allow_missing:
        parent = p.parent
        if not parent.exists() or not _inside(root, parent):
            raise FileManagerError("Destination is outside the hosting root.")
        if not _inside(root, parent):
            raise FileManagerError("Destination is outside the hosting root.")
    else:
        raise FileManagerError("File or directory not found.")
    return p

def relative(root, path):
    root = _root(root)
    p = Path(os.path.abspath(str(path)))
    if not _inside(root, p):
        raise FileManagerError("Path is outside the hosting root.")
    r = os.path.relpath(str(p), str(root))
    return "." if r == "." else r.replace(os.sep, "/")

def _display_name(p):
    return p.name or "/"

def _owner(st):
    try: owner = pwd.getpwuid(st.st_uid).pw_name
    except KeyError: owner = str(st.st_uid)
    try: group = grp.getgrgid(st.st_gid).gr_name
    except KeyError: group = str(st.st_gid)
    return owner, group

def _kind(st):
    if stat.S_ISDIR(st.st_mode): return "dir"
    if stat.S_ISLNK(st.st_mode): return "symlink"
    if stat.S_ISREG(st.st_mode): return "file"
    return "other"

def _human_size(n):
    n = int(n or 0); units = ["B", "KB", "MB", "GB", "TB"]; x = float(n)
    for u in units:
        if x < 1024 or u == units[-1]:
            return f"{int(x)} B" if u == "B" else f"{x:.1f} {u}"
        x /= 1024
    return "0 B"

def _mode(st):
    return stat.filemode(st.st_mode), oct(stat.S_IMODE(st.st_mode))[2:].zfill(4)

def stat_entry(root, p, *, is_root=False):
    st = os.lstat(p)
    owner, group = _owner(st)
    mode, octal = _mode(st)
    target = ""
    if stat.S_ISLNK(st.st_mode):
        try: target = os.readlink(p)
        except OSError: target = ""
    return {
        "name": _display_name(p), "path": relative(root, p), "kind": _kind(st),
        "is_dir": stat.S_ISDIR(st.st_mode), "size": int(st.st_size if stat.S_ISREG(st.st_mode) else 0),
        "size_human": _human_size(st.st_size if stat.S_ISREG(st.st_mode) else 0),
        "modified": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)),
        "mtime": st.st_mtime, "owner": owner, "group": group, "mode": mode, "octal": octal,
        "target": target, "is_root": is_root,
    }

def list_dir(root, relative_path="."):
    root = _root(root); p = safe_path(root, relative_path)
    if not p.is_dir(): raise FileManagerError("Selected path is not a directory.")
    rows=[]
    for child in sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())):
        try:
            # A symlink to outside is shown as blocked, but never followed.
            if child.is_symlink() and not _inside(root, child):
                st=os.lstat(child); owner,group=_owner(st); mode,octal=_mode(st)
                rows.append({"name":child.name,"path":os.path.relpath(str(child),str(root)).replace(os.sep,"/"),"kind":"blocked-link","is_dir":False,
                             "size":0,"size_human":"—","modified":time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(st.st_mtime)),
                             "mtime":st.st_mtime,"owner":owner,"group":group,"mode":mode,"octal":octal,
                             "target":os.readlink(child),"is_root":False})
                continue
            rows.append(stat_entry(root, child))
        except OSError:
            continue
    return rows

def read_text(root, relative_path):
    p = safe_path(root, relative_path, allow_root=False)
    if not p.is_file() or p.is_symlink(): raise FileManagerError("Only regular files can be edited.")
    size=p.stat().st_size
    if size > MAX_EDITOR_BYTES: raise FileManagerError(f"Editor limit is {_human_size(MAX_EDITOR_BYTES)}.")
    try: return p.read_text(encoding="utf-8")
    except UnicodeDecodeError: raise FileManagerError("This file is not valid UTF-8 text.")

def write_text(root, relative_path, content):
    p=safe_path(root, relative_path, allow_missing=True, allow_root=False)
    if p.exists() and (not p.is_file() or p.is_symlink()): raise FileManagerError("Only regular files can be edited.")
    if len(content.encode("utf-8")) > MAX_EDITOR_BYTES: raise FileManagerError("Editor content is too large.")
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(content, encoding="utf-8")

def create_dir(root, parent, name):
    if not NAME_RE.fullmatch(name or ""): raise FileManagerError("Invalid directory name.")
    p=safe_path(root,parent); target=safe_path(root, relative(root,p)+"/"+name, allow_missing=True, allow_root=False)
    target.mkdir()

def create_file(root, parent, name):
    if not NAME_RE.fullmatch(name or ""): raise FileManagerError("Invalid file name.")
    p=safe_path(root,parent); target=safe_path(root, relative(root,p)+"/"+name, allow_missing=True, allow_root=False)
    if target.exists(): raise FileManagerError("A file or directory with that name already exists.")
    target.touch()

def upload(root, parent, file_storage):
    name=os.path.basename(file_storage.filename or "")
    if not NAME_RE.fullmatch(name): raise FileManagerError("Invalid upload filename.")
    p=safe_path(root,parent); target=safe_path(root, relative(root,p)+"/"+name, allow_missing=True, allow_root=False)
    if target.exists(): raise FileManagerError("A file with that name already exists.")
    file_storage.save(str(target))

def rename(root, source, new_name):
    if not NAME_RE.fullmatch(new_name or ""): raise FileManagerError("Invalid new name.")
    src=safe_path(root,source,allow_root=False); dst=safe_path(root, relative(root,src.parent)+"/"+new_name,allow_missing=True,allow_root=False)
    if dst.exists() or dst.is_symlink(): raise FileManagerError("Destination already exists.")
    src.rename(dst)

def delete(root, source):
    p=safe_path(root,source,allow_root=False)
    if p.is_symlink() or p.is_file(): p.unlink()
    elif p.is_dir(): shutil.rmtree(p)
    else: raise FileManagerError("Unsupported filesystem object.")

def _copytree(src,dst):
    if dst.exists(): raise FileManagerError("Destination already exists.")
    shutil.copytree(src,dst,symlinks=False)

def copy_item(root, source, destination):
    src=safe_path(root,source,allow_root=False); dst=safe_path(root,destination,allow_missing=True,allow_root=False)
    if dst.exists() or dst.is_symlink(): raise FileManagerError("Destination already exists.")
    if src.is_symlink(): raise FileManagerError("Copying symlinks is blocked for isolation.")
    if src.is_dir(): _copytree(src,dst)
    else: shutil.copy2(src,dst)

def move_item(root, source, destination):
    src=safe_path(root,source,allow_root=False); dst=safe_path(root,destination,allow_missing=True,allow_root=False)
    if dst.exists() or dst.is_symlink(): raise FileManagerError("Destination already exists.")
    if src.is_symlink(): raise FileManagerError("Moving symlinks is blocked for isolation.")
    if src.is_dir() and _inside(root,dst) and os.path.commonpath([os.path.abspath(str(src)),os.path.abspath(str(dst))])==os.path.abspath(str(src)):
        raise FileManagerError("Cannot move a directory into itself.")
    shutil.move(str(src),str(dst))

def chmod(root, source, mode):
    p=safe_path(root,source,allow_root=False)
    m=str(mode or "").strip()
    if not re.fullmatch(r"[0-7]{3,4}",m): raise FileManagerError("Permission must be an octal value such as 0644 or 0755.")
    value=int(m,8)
    if value & ~0o7777: raise FileManagerError("Invalid permission bits.")
    os.chmod(p,value,follow_symlinks=False)

def chown(root, source, owner, group, allowed_users=None, allowed_groups=None):
    p=safe_path(root,source,allow_root=False)
    if not owner or not group: raise FileManagerError("Owner and group are required.")
    try: uid=pwd.getpwnam(owner).pw_uid
    except KeyError: raise FileManagerError("Unknown owner.")
    try: gid=grp.getgrnam(group).gr_gid
    except KeyError: raise FileManagerError("Unknown group.")
    if allowed_users is not None and owner not in set(allowed_users):
        raise FileManagerError("Ownership can only be assigned to the hosting account user.")
    if allowed_groups is not None and group not in set(allowed_groups):
        raise FileManagerError("Group ownership can only use the hosting account group.")
    os.chown(p,uid,gid,follow_symlinks=False)

def search(root, query, start="."):
    root=_root(root); base=safe_path(root,start)
    q=(query or "").strip().lower()
    if not q: raise FileManagerError("Enter a search term.")
    out=[]; stack=[base]; visited=set()
    while stack and len(out)<MAX_SEARCH_RESULTS:
        cur=stack.pop()
        try:
            real=os.path.realpath(cur)
            if real in visited: continue
            visited.add(real)
            for child in cur.iterdir():
                if len(out)>=MAX_SEARCH_RESULTS: break
                if child.is_symlink():
                    if not _inside(root,child): continue
                    try: st=os.lstat(child)
                    except OSError: continue
                    if q in child.name.lower(): out.append(stat_entry(root,child))
                    continue
                if q in child.name.lower():
                    try: out.append(stat_entry(root,child))
                    except OSError: pass
                if child.is_dir(): stack.append(child)
        except (OSError,PermissionError): continue
    return out

def _zip_member_safe(root,dest_root,name):
    name=name.replace("\\","/")
    if name.startswith("/") or any(x==".." for x in name.split("/")):
        raise FileManagerError("ZIP contains an unsafe path.")
    target=dest_root.joinpath(*[x for x in name.split("/") if x])
    if not _inside(root,target.parent): raise FileManagerError("ZIP extraction escaped hosting root.")
    return target

def zip_items(root, sources, destination):
    root=_root(root); dest=safe_path(root,destination,allow_missing=True,allow_root=False)
    dest.parent.mkdir(parents=True,exist_ok=True)
    if dest.exists(): raise FileManagerError("ZIP destination already exists.")
    srcs=[safe_path(root,s,allow_root=False) for s in sources if s]
    if not srcs: raise FileManagerError("Select at least one item to archive.")
    with zipfile.ZipFile(dest,"w",zipfile.ZIP_DEFLATED) as z:
        for src in srcs:
            base=src.name
            if src.is_symlink(): raise FileManagerError("Symlinks are not included in archives.")
            if src.is_dir():
                for cur,dirs,files in os.walk(src,topdown=True,followlinks=False):
                    dirs[:]=[d for d in dirs if not Path(cur,d).is_symlink()]
                    rel=os.path.relpath(cur,src.parent)
                    z.write(cur,rel)
                    for f in files:
                        fp=Path(cur,f)
                        if fp.is_symlink(): continue
                        z.write(fp,os.path.relpath(fp,src.parent))
            else: z.write(src,base)
    return relative(root,dest)

def extract_zip(root, archive, destination):
    root=_root(root); src=safe_path(root,archive,allow_root=False); dest=safe_path(root,destination,allow_missing=True,allow_root=False)
    if not src.is_file() or src.is_symlink(): raise FileManagerError("Archive must be a regular ZIP file.")
    dest.mkdir(parents=True,exist_ok=True)
    with zipfile.ZipFile(src) as z:
        for info in z.infolist():
            target=_zip_member_safe(root,dest,info.filename)
            # Block symlink entries (Unix mode stored in ZIP metadata).
            mode=(info.external_attr >> 16) & 0o170000
            if mode == stat.S_IFLNK: raise FileManagerError("ZIP symlink entries are blocked.")
            if info.is_dir(): target.mkdir(parents=True,exist_ok=True); continue
            target.parent.mkdir(parents=True,exist_ok=True)
            with z.open(info,"r") as rf, open(target,"wb") as wf: shutil.copyfileobj(rf,wf,1024*1024)
    return relative(root,dest)

def current_owner_choices(root, account_user=None):
    choices=[]
    if account_user:
        try: pwd.getpwnam(account_user); choices.append(account_user)
        except KeyError: pass
    return choices
