#!/usr/bin/python3 -u
import os
import re
import sys
import pwd
import subprocess
import tempfile
import fcntl
import xml.etree.ElementTree as ET
from datetime import datetime

PASSWD_FILE = '/etc/proftpd/sftpd_passwd'
GROUP_FILE  = '/etc/proftpd/sftpd_group'
LOG_FILE    = '/usr/local/mgr5/var/sftp_plugin_error.log'
MGRCTL      = '/usr/local/mgr5/sbin/mgrctl'
MOD_XML     = '/usr/local/mgr5/etc/xml/ispmgr_mod_sftpuser.xml'


def load_lang_messages(func_name, lang='en'):
    try:
        tree = ET.parse(MOD_XML)
        for lang_el in tree.getroot().findall('lang'):
            if lang_el.get('name') != lang:
                continue
            for msgs in lang_el.findall('messages'):
                if msgs.get('name') == func_name:
                    return {m.get('name'): (m.text or '') for m in msgs.findall('msg')}
    except Exception as e:
        log_error(f"load_lang_messages failed: {e}")
    return {}

NAME_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9.\-]{0,31}$')


def log_error(msg):
    try:
        with open(LOG_FILE, 'a') as f:
            f.write(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] ERROR: {msg}\n")
    except Exception:
        pass


def init_files():
    if not os.path.exists(PASSWD_FILE):
        open(PASSWD_FILE, 'w').close()
        os.chmod(PASSWD_FILE, 0o640)
        os.chown(PASSWD_FILE, 0, 0)
    if not os.path.exists(GROUP_FILE):
        open(GROUP_FILE, 'w').close()
        os.chmod(GROUP_FILE, 0o640)
        os.chown(GROUP_FILE, 0, 0)


def hash_password(password):
    if not password:
        return None
    try:
        res = subprocess.run(
            ['openssl', 'passwd', '-1', '-stdin'],
            input=password,
            capture_output=True, text=True, check=True, timeout=10
        )
        return res.stdout.strip()
    except Exception as e:
        log_error(f"Hashing failed: {e}")
        return None


def check_user_level():
    level = int(os.environ.get('AUTH_LEVEL', '0'))
    return level >= 30


def get_owners():
    owners = []
    try:
        res = subprocess.run(
            [MGRCTL, '-m', 'ispmgr', 'user', 'out=xml'],
            capture_output=True, text=True, check=True, timeout=10
        )
        tree = ET.fromstring(res.stdout)
        for elem in tree.findall('.//elem'):
            name_el = elem.find('name')
            if name_el is None or not name_el.text:
                continue
            uname = name_el.text.strip()
            try:
                pw = pwd.getpwnam(uname)
            except KeyError:
                continue
            owners.append({'name': uname, 'home': pw.pw_dir, 'uid': pw.pw_uid, 'gid': pw.pw_gid})
    except Exception as e:
        log_error(f"get_owners failed: {e}")
    return owners


def find_owner(owners, name):
    for o in owners:
        if o['name'] == name:
            return o
    return None


def owner_by_uid(owners, uid):
    for o in owners:
        if o['uid'] == uid:
            return o
    return None


class ValidationError(Exception):
    def __init__(self, field, key):
        super().__init__(key)
        self.field = field
        self.key = key


def resolve_home(owner, home_rel):
    home_rel = (home_rel or '.').lstrip('/')
    base_real = os.path.realpath(owner['home'])
    home_abs = os.path.normpath(os.path.join(owner['home'], home_rel))
    real = os.path.realpath(home_abs)

    if real != base_real and not real.startswith(base_real + os.sep):
        raise ValidationError('home', 'err_home_outside')

    if os.path.exists(home_abs) and not os.path.isdir(home_abs):
        raise ValidationError('home', 'err_home_not_dir')

    return real


def build_slist(parent, name, pairs):
    slist = ET.SubElement(parent, 'slist', {'name': name})
    for key, value in pairs:
        val = ET.SubElement(slist, 'val', {'key': key})
        val.text = value


# --- passwd / group ----------------------------------------------------------

def read_users():
    users = []
    owners = get_owners()
    if os.path.exists(PASSWD_FILE):
        with open(PASSWD_FILE, 'r') as f:
            for line in f:
                parts = line.strip().split(':')
                if len(parts) >= 6 and parts[0]:
                    uid = int(parts[2]) if parts[2].isdigit() else None
                    o = owner_by_uid(owners, uid) if uid is not None else None
                    users.append({
                        'name': parts[0],
                        'passwd': parts[1],
                        'uid': parts[2],
                        'home': parts[5],
                        'owner': o['name'] if o else '',
                    })
    return users


def _atomic_write(filepath, content, mode=0o640):
    dir_name = os.path.dirname(filepath)
    fd, tmp_path = tempfile.mkstemp(dir=dir_name, suffix='.tmp')
    try:
        with os.fdopen(fd, 'w') as f:
            f.write(content)
            f.flush()
            os.fsync(f.fileno())
        os.rename(tmp_path, filepath)
        os.chmod(filepath, mode)
    except Exception:
        try:
            os.unlink(tmp_path)
        except OSError:
            pass
        raise


def _lock_file(filepath):
    lock_path = filepath + '.lock'
    lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o640)
    fcntl.flock(lock_fd, fcntl.LOCK_EX)
    return lock_fd, lock_path


def _unlock_file(lock_fd, lock_path):
    fcntl.flock(lock_fd, fcntl.LOCK_UN)
    os.close(lock_fd)
    try:
        os.unlink(lock_path)
    except OSError:
        pass


def save_user(name, passwd, home_rel, owner_name, is_new):
    if not NAME_RE.match(name or ''):
        raise ValidationError('name', 'err_invalid_login')

    owners = get_owners()
    owner = find_owner(owners, owner_name)
    if owner is None:
        raise ValidationError('owner', 'err_unknown_owner')

    home_abs = resolve_home(owner, home_rel)

    if is_new and not passwd:
        raise ValidationError('passwd', 'err_passwd_required')

    new_hash = hash_password(passwd)

    lock_fd, lock_path = _lock_file(PASSWD_FILE)
    try:
        users = []
        updated = False
        if os.path.exists(PASSWD_FILE):
            with open(PASSWD_FILE, 'r') as f:
                for line in f:
                    parts = line.strip().split(':')
                    if parts and parts[0] == name:
                        if is_new:
                            raise ValidationError('name', 'err_name_exists')
                        existing_hash = parts[1] if len(parts) > 1 else '*'
                        actual_hash = new_hash if new_hash is not None else existing_hash
                        users.append(f"{name}:{actual_hash}:{owner['uid']}:{owner['gid']}::{home_abs}:/bin/false")
                        updated = True
                    elif line.strip():
                        users.append(line.strip())
        if not updated:
            users.append(f"{name}:{new_hash or '*'}:{owner['uid']}:{owner['gid']}::{home_abs}:/bin/false")

        _atomic_write(PASSWD_FILE, '\n'.join(users) + '\n')
    finally:
        _unlock_file(lock_fd, lock_path)

    update_group(owner, name)

    try:
        if not os.path.exists(home_abs):
            os.makedirs(home_abs, exist_ok=True)
        os.chown(home_abs, owner['uid'], owner['gid'], follow_symlinks=False)
        os.chmod(home_abs, 0o755)
    except Exception as e:
        log_error(f"FS ops failed for {home_abs}: {e}")


def update_group(owner, login):
    lock_fd, lock_path = _lock_file(GROUP_FILE)
    try:
        lines = []
        found = False
        if os.path.exists(GROUP_FILE):
            with open(GROUP_FILE, 'r') as f:
                for line in f:
                    parts = line.strip().split(':')
                    if len(parts) >= 4 and parts[0] == owner['name']:
                        members = set(m for m in parts[3].split(',') if m)
                        members.add(login)
                        lines.append(f"{owner['name']}:x:{owner['gid']}:{','.join(sorted(members))}")
                        found = True
                    elif line.strip():
                        lines.append(line.strip())
        if not found:
            lines.append(f"{owner['name']}:x:{owner['gid']}:{login}")
        _atomic_write(GROUP_FILE, '\n'.join(lines) + '\n')
    finally:
        _unlock_file(lock_fd, lock_path)


def remove_from_group(login):
    if not os.path.exists(GROUP_FILE):
        return
    lock_fd, lock_path = _lock_file(GROUP_FILE)
    try:
        lines = []
        with open(GROUP_FILE, 'r') as f:
            for line in f:
                parts = line.strip().split(':')
                if len(parts) >= 4:
                    members = [m for m in parts[3].split(',') if m and m != login]
                    if members:
                        lines.append(f"{parts[0]}:x:{parts[2]}:{','.join(members)}")
                elif line.strip():
                    lines.append(line.strip())
        _atomic_write(GROUP_FILE, '\n'.join(lines) + ('\n' if lines else ''))
    finally:
        _unlock_file(lock_fd, lock_path)


def delete_users(elids):
    if not os.path.exists(PASSWD_FILE):
        return
    lock_fd, lock_path = _lock_file(PASSWD_FILE)
    try:
        users = []
        with open(PASSWD_FILE, 'r') as f:
            for line in f:
                parts = line.strip().split(':')
                if parts and parts[0] and parts[0] not in elids:
                    users.append(line.strip())
        _atomic_write(PASSWD_FILE, '\n'.join(users) + '\n')
    finally:
        _unlock_file(lock_fd, lock_path)
    for login in elids:
        remove_from_group(login)


# --- main --------------------------------------------------------------------

try:
    if not check_user_level():
        root = ET.Element('doc', {'func': 'error'})
        ET.SubElement(ET.SubElement(root, 'error'), 'msg', {'name': 'body'}).text = 'Access denied'
        ET.dump(root)
        sys.exit(0)

    init_files()

    root = ET.parse(sys.stdin).getroot()

    PARAM_func   = os.environ.get('PARAM_func', '')
    PARAM_sok    = os.environ.get('PARAM_sok', '')
    PARAM_name   = os.environ.get('PARAM_name', '')
    PARAM_passwd = os.environ.get('PARAM_passwd', '')
    PARAM_home   = os.environ.get('PARAM_home', '')
    PARAM_owner  = os.environ.get('PARAM_owner', '')
    PARAM_elid   = os.environ.get('PARAM_elid', '')

    if PARAM_func == 'sftpuser.edit':
        if PARAM_sok == 'ok':
            save_user(PARAM_name, PARAM_passwd, PARAM_home, PARAM_owner, is_new=not PARAM_elid)
            ET.SubElement(root, 'ok', {'msg': load_lang_messages('sftpuser.edit').get('msg_save', 'Saved')})
        else:
            owners = get_owners()

            existing = None
            if PARAM_elid:
                for u in read_users():
                    if u['name'] == PARAM_elid:
                        existing = u
                        break

            selected_owner = PARAM_owner or (existing['owner'] if existing else '') \
                or (owners[0]['name'] if owners else '')

            build_slist(root, 'owner', [(o['name'], o['name']) for o in owners])

            if PARAM_elid:
                ET.SubElement(root, 'name').text = existing['name'] if existing else PARAM_elid
                if existing:
                    owner_obj = find_owner(owners, selected_owner)
                    home_rel = os.path.relpath(existing['home'], owner_obj['home']) if owner_obj else existing['home']
                    ET.SubElement(root, 'home').text = home_rel
            if selected_owner:
                ET.SubElement(root, 'owner').text = selected_owner

    elif PARAM_func == 'sftpuser.delete' and PARAM_elid:
        elids_to_delete = [x.strip() for x in PARAM_elid.split(',') if x.strip()]
        delete_users(elids_to_delete)
        ET.SubElement(root, 'ok', {'msg': load_lang_messages('sftpuser').get('msg_delete', 'Deleted')})

    else:
        for u in read_users():
            elem = ET.SubElement(root, 'elem')
            ET.SubElement(elem, 'name').text = u['name']
            ET.SubElement(elem, 'owner').text = u['owner']
            ET.SubElement(elem, 'home').text = u['home']

    ET.dump(root)

except ValidationError as e:
    text = load_lang_messages('sftpuser.edit').get(e.key, e.key)
    root = ET.Element('doc', {'func': 'error'})
    err = ET.SubElement(root, 'error', {'object': e.field})
    ET.SubElement(err, 'msg', {'name': 'body'}).text = text
    ET.dump(root)

except Exception as e:
    log_error(str(e))
    root = ET.Element('doc', {'func': 'error'})
    ET.SubElement(ET.SubElement(root, 'error'), 'msg', {'name': 'body'}).text = str(e)
    ET.dump(root)