#!/usr/bin/env python3
import json
import re
import subprocess
from collections import defaultdict
from enum import Enum
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Set, Tuple

import typer
from typer import Context

app = typer.Typer(help="mailcow-share-manager — Share admin tool for Mailcow (SOGo + Dovecot)")

# ----------------- Defaults -----------------
DEFAULT_MC_DIR = "/opt/mailcow-dockerized"
DEFAULT_COMPOSE_NAME = "mailcow"
DEFAULT_COMPOSE_FILE = "docker-compose.yml"
DEFAULT_MAILCOW_CONF = "mailcow.conf"

DEFAULT_DB_CONTAINER = "mysql-mailcow"
DEFAULT_SOGO_CONTAINER = "sogo-mailcow"
DEFAULT_DOVECOT_CONTAINER = "dovecot-mailcow"

SOGO_TABLE_PREFIX = "sogo_"

# mailcow doc: custom dovecot settings live here
DOVECOT_EXTRA_REL = Path("data/conf/dovecot/extra.conf")

# managed block markers inside extra.conf (idempotent, safe)
INH_BEGIN = "# BEGIN mailcow-share-manager:acl-inheritance\n"
INH_END = "# END mailcow-share-manager:acl-inheritance\n"


class ShareType(str, Enum):
    calendar = "calendar"
    addressbook = "addressbook"
    mailbox = "mailbox"


# ----------------- Role aliases (SOGo) -----------------
# Calendar: "write" should NOT include PrivateModifier -> private events show busy only (date/time),
# but details remain hidden.
SOGO_CAL_READ: Set[str] = {
     "Viewer",
     "ObjectViewer",
     "PrivateDandTViewer",
     "PublicViewer",
     }

SOGO_CAL_WRITE_NOPRIVATE: Set[str] = {
    "Editor",
    "ObjectCreator",
    "ObjectEraser",
    "PublicModifier",
    "ConfidentialModifier",
    # deliberately NOT: "PrivateModifier"
}
SOGO_CAL_WRITE_FULL: Set[str] = set(SOGO_CAL_WRITE_NOPRIVATE) | {"PrivateModifier"}

SOGO_AB_READ: Set[str] = {"ObjectViewer"}
SOGO_AB_WRITE: Set[str] = {"Editor", "ObjectCreator", "ObjectEditor", "ObjectEraser", "ObjectViewer"}

# Aliases accepted as input for --role (SOGo only)
SOGO_ALIAS_MAP: Dict[str, Dict[str, Set[str]]] = {
    "calendar": {
        "read": SOGO_CAL_READ,
        "write": SOGO_CAL_WRITE_NOPRIVATE,  # your desired default
        "write-full": SOGO_CAL_WRITE_FULL,  # if you ever want private editable/visible
    },
    "addressbook": {
        "read": SOGO_AB_READ,
        "write": SOGO_AB_WRITE,
    },
}

# For listing: prefer to show a single nice alias if it matches exactly
SOGO_ALIAS_PREFERRED_ORDER = [
    ("calendar", "read", SOGO_CAL_READ),
    ("calendar", "write", SOGO_CAL_WRITE_NOPRIVATE),
    ("calendar", "write-full", SOGO_CAL_WRITE_FULL),
    ("addressbook", "read", SOGO_AB_READ),
    ("addressbook", "write", SOGO_AB_WRITE),
]


# ----------------- Root behavior -----------------
@app.callback(invoke_without_command=True)
def main(ctx: Context):
    """If called without a subcommand, print help (list subcommands)."""
    if ctx.invoked_subcommand is None:
        typer.echo(ctx.get_help())
        raise typer.Exit(0)


# ----------------- process helpers -----------------
def run(cmd: List[str], check: bool = True) -> subprocess.CompletedProcess:
    return subprocess.run(cmd, check=check, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)


def docker_compose_ls() -> str:
    try:
        cp = run(["docker", "compose", "ls"], check=True)
        return cp.stdout or ""
    except Exception:
        return ""


def docker_ps_labels() -> str:
    try:
        fmt = (
            "{{.ID}}\t{{.Names}}\t{{.Label \"com.docker.compose.project.working_dir\"}}"
            "\t{{.Label \"com.docker.compose.project\"}}"
        )
        cp = run(["docker", "ps", "--format", fmt], check=True)
        return cp.stdout or ""
    except Exception:
        return ""


def dc_exec(
    compose_file: str,
    service: str,
    args: List[str],
    user: Optional[str] = None,
    tty: bool = False,
) -> subprocess.CompletedProcess:
    base = ["docker", "compose", "-f", compose_file, "exec"]
    if not tty:
        base.append("-T")
    if user:
        base += ["-u", user]
    base.append(service)
    base += args
    return run(base, check=True)


def dc_down_up(compose_file: str) -> None:
    run(["docker", "compose", "-f", compose_file, "down"], check=True)
    run(["docker", "compose", "-f", compose_file, "up", "-d"], check=True)


def dc_restart_services(compose_file: str, services: List[str]) -> None:
    run(["docker", "compose", "-f", compose_file, "restart"] + services, check=True)


# ----------------- project discovery -----------------
def looks_like_mailcow_dir(path: Path) -> bool:
    return (path / DEFAULT_COMPOSE_FILE).exists() and (path / DEFAULT_MAILCOW_CONF).exists()


def discover_project_dir(user_project_dir: Optional[str]) -> Path:
    if user_project_dir:
        p = Path(user_project_dir).expanduser().resolve()
        if looks_like_mailcow_dir(p):
            return p
        raise typer.BadParameter(
            f"--project-dir '{p}' does not look like a mailcow directory "
            f"(missing {DEFAULT_COMPOSE_FILE}/{DEFAULT_MAILCOW_CONF})"
        )

    default = Path(DEFAULT_MC_DIR)
    if looks_like_mailcow_dir(default):
        return default

    ls_out = docker_compose_ls()
    for line in ls_out.splitlines():
        if not line.strip() or line.lower().startswith("name"):
            continue
        if DEFAULT_COMPOSE_NAME in line:
            m = re.search(r"(/[^ ]*docker-compose\.ya?ml)", line)
            if m:
                compose_path = Path(m.group(1))
                pdir = compose_path.parent
                if looks_like_mailcow_dir(pdir):
                    return pdir

    ps_out = docker_ps_labels()
    for line in ps_out.splitlines():
        parts = line.split("\t")
        if len(parts) < 4:
            continue
        _cid, _name, working_dir, project = parts
        if project == DEFAULT_COMPOSE_NAME and working_dir:
            pdir = Path(working_dir)
            if looks_like_mailcow_dir(pdir):
                return pdir

    raise typer.Exit(
        "Could not discover mailcow project directory.\n"
        "Tried default /opt/mailcow-dockerized, then 'docker compose ls', then container labels.\n"
        "Use: mailcow-share-manager --project-dir /path/to/mailcow-dockerized …"
    )


# ----------------- config / credentials -----------------
def read_mailcow_conf(conf_path: Path) -> dict:
    conf = {}
    for raw in conf_path.read_text(encoding="utf-8").splitlines():
        line = raw.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        k, v = line.split("=", 1)
        conf[k.strip()] = v.strip().strip('"').strip("'")
    return conf


def resolve_db_name(conf: dict, db_name_override: Optional[str]) -> str:
    return db_name_override or conf.get("DBNAME", "mailcow")


def resolve_db_credentials(
    conf: dict,
    db_user_override: Optional[str],
    db_pass_override: Optional[str],
) -> Tuple[str, str]:
    conf_user = conf.get("DBUSER", "root")
    conf_pass = conf.get("DBPASS", "")
    conf_root = conf.get("DBROOT", "")

    if db_user_override and db_pass_override:
        return db_user_override, db_pass_override

    if db_user_override and not db_pass_override:
        if db_user_override == "root":
            if not conf_root:
                raise RuntimeError("DBROOT not set in mailcow.conf")
            return "root", conf_root
        if not conf_pass:
            raise RuntimeError("DBPASS not set in mailcow.conf (needed for non-root DB user)")
        return db_user_override, conf_pass

    if db_pass_override and not db_user_override:
        return conf_user, db_pass_override

    if conf_user != "root":
        if not conf_pass:
            raise RuntimeError("DBPASS not set in mailcow.conf")
        return conf_user, conf_pass

    if not conf_root:
        raise RuntimeError("DBROOT not set in mailcow.conf")
    return "root", conf_root


def mysql_query(
    compose_file: str,
    db_container: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    sql: str,
) -> str:
    args = ["mysql", f"-u{db_user}", f"-p{db_pass}", "-N", "-B", db_name, "-e", sql]
    try:
        cp = dc_exec(compose_file, db_container, args)
        return (cp.stdout or "").strip()
    except subprocess.CalledProcessError as e:
        msg = (e.stderr or "").strip() or (e.stdout or "").strip() or "mysql command failed"
        raise RuntimeError(f"MySQL query failed (user={db_user}, db={db_name}). {msg}") from e


# ----------------- output -----------------
def print_table(rows: List[List[str]], headers: List[str]) -> None:
    if not rows:
        typer.echo("No results.")
        return
    col_widths = [len(h) for h in headers]
    for r in rows:
        for i, v in enumerate(r):
            col_widths[i] = max(col_widths[i], len(str(v)))
    fmt = "  ".join("{:<" + str(w) + "}" for w in col_widths)
    typer.echo(fmt.format(*headers))
    typer.echo(fmt.format(*["-" * w for w in col_widths]))
    for r in rows:
        typer.echo(fmt.format(*[str(x) for x in r]))


# ----------------- Common validation -----------------
def reject_user_equals(grantee: str) -> None:
    if (grantee or "").strip().lower().startswith("user="):
        raise typer.BadParameter("Do not use 'user=' in --grantee. Provide the email address only.")


# ----------------- SOGo helpers -----------------
def sogo_parse_role_input(typ: ShareType, role: str) -> Set[str]:
    r = (role or "").strip()
    if not r:
        return set()

    if r.startswith("["):
        try:
            arr = json.loads(r)
            if isinstance(arr, list):
                return {str(x).strip() for x in arr if str(x).strip()}
        except Exception:
            pass

    if "," in r:
        return {x.strip() for x in r.split(",") if x.strip()}

    alias_map = SOGO_ALIAS_MAP.get(typ.value, {})
    if r in alias_map:
        return set(alias_map[r])

    return {r}


def sogo_roles_to_display(type_str: str, roles: Set[str]) -> str:
    roles_norm = {r.strip() for r in roles if r.strip()}
    for t, alias, role_set in SOGO_ALIAS_PREFERRED_ORDER:
        if t == type_str and roles_norm == role_set:
            if t == "calendar" and alias == "write":
                return "write (private busy-only)"
            return alias
    return ",".join(sorted(roles_norm))


def sogo_roles_to_json_array(roles: Iterable[str]) -> str:
    arr = [r for r in sorted({x.strip() for x in roles if str(x).strip()})]
    return json.dumps(arr)


def sogo_acl_add(compose_file: str, owner: str, path: str, grantee: str, roles: Set[str]) -> None:
    reject_user_equals(grantee)
    if not roles:
        raise typer.BadParameter("SOGo: empty role set")
    roles_json = sogo_roles_to_json_array(roles)
    cp = dc_exec(
        compose_file,
        DEFAULT_SOGO_CONTAINER,
        ["sogo-tool", "manage-acl", "add", owner, path, grantee.strip(), roles_json],
        user="sogo",
        tty=False,
    )
    if cp.stdout and cp.stdout.strip():
        typer.echo(cp.stdout.strip())
    if cp.stderr and cp.stderr.strip():
        typer.echo(cp.stderr.strip())


def sogo_acl_subscribe(compose_file: str, owner: str, path: str, grantee: str, roles: Set[str]) -> None:
    reject_user_equals(grantee)
    if not roles:
        raise typer.BadParameter("SOGo: empty role set")
    roles_json = sogo_roles_to_json_array(roles)
    cp = dc_exec(
        compose_file,
        DEFAULT_SOGO_CONTAINER,
        ["sogo-tool", "manage-acl", "subscribe", owner, path, grantee.strip(), roles_json],
        user="sogo",
        tty=False,
    )
    if cp.stdout and cp.stdout.strip():
        typer.echo(cp.stdout.strip())
    if cp.stderr and cp.stderr.strip():
        typer.echo(cp.stderr.strip())


def sogo_acl_unsubscribe(compose_file: str, owner: str, path: str, grantee: str) -> None:
    reject_user_equals(grantee)
    try:
        cp = dc_exec(
            compose_file,
            DEFAULT_SOGO_CONTAINER,
            ["sogo-tool", "manage-acl", "unsubscribe", owner, path, grantee.strip()],
            user="sogo",
            tty=False,
        )
        if cp.stdout and cp.stdout.strip():
            typer.echo(cp.stdout.strip())
        if cp.stderr and cp.stderr.strip():
            typer.echo(cp.stderr.strip())
        return
    except subprocess.CalledProcessError:
        # fallback (best-effort)
        try:
            cp = dc_exec(
                compose_file,
                DEFAULT_SOGO_CONTAINER,
                ["sogo-tool", "manage-acl", "unsubscribe", owner, path, grantee.strip(), "[]"],
                user="sogo",
                tty=False,
            )
            if cp.stdout and cp.stdout.strip():
                typer.echo(cp.stdout.strip())
            if cp.stderr and cp.stderr.strip():
                typer.echo(cp.stderr.strip())
        except subprocess.CalledProcessError:
            pass


def sogo_acl_remove(compose_file: str, owner: str, path: str, grantee: str, roles: Optional[Set[str]] = None) -> None:
    reject_user_equals(grantee)

    if roles:
        roles_json = sogo_roles_to_json_array(roles)
        try:
            cp = dc_exec(
                compose_file,
                DEFAULT_SOGO_CONTAINER,
                ["sogo-tool", "manage-acl", "remove", owner, path, grantee.strip(), roles_json],
                user="sogo",
                tty=False,
            )
            if cp.stdout and cp.stdout.strip():
                typer.echo(cp.stdout.strip())
            if cp.stderr and cp.stderr.strip():
                typer.echo(cp.stderr.strip())
            return
        except subprocess.CalledProcessError:
            pass

        for role in sorted(roles):
            try:
                cp = dc_exec(
                    compose_file,
                    DEFAULT_SOGO_CONTAINER,
                    ["sogo-tool", "manage-acl", "remove", owner, path, grantee.strip(), sogo_roles_to_json_array([role])],
                    user="sogo",
                    tty=False,
                )
                if cp.stdout and cp.stdout.strip():
                    typer.echo(cp.stdout.strip())
                if cp.stderr and cp.stderr.strip():
                    typer.echo(cp.stderr.strip())
            except subprocess.CalledProcessError:
                pass
        return

    try:
        cp = dc_exec(
            compose_file,
            DEFAULT_SOGO_CONTAINER,
            ["sogo-tool", "manage-acl", "remove", owner, path, grantee.strip()],
            user="sogo",
            tty=False,
        )
        if cp.stdout and cp.stdout.strip():
            typer.echo(cp.stdout.strip())
        if cp.stderr and cp.stderr.strip():
            typer.echo(cp.stderr.strip())
    except subprocess.CalledProcessError:
        pass


# ----------------- SOGo DB listing (shares) -----------------
def sogo_list_shares_raw(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    owner: Optional[str],
    typ: Optional[ShareType],
) -> List[List[str]]:
    fi = f"{SOGO_TABLE_PREFIX}folder_info"
    acl = f"{SOGO_TABLE_PREFIX}acl"

    where = []
    if owner:
        where.append(f"fi.c_path LIKE '%/Users/{owner}/%'")

    if typ == ShareType.calendar:
        where.append("fi.c_folder_type='Appointment'")
    elif typ == ShareType.addressbook:
        where.append("fi.c_folder_type='Contact'")
    else:
        where.append("fi.c_folder_type IN ('Appointment','Contact')")

    where_sql = "WHERE " + " AND ".join(where)

    sql = f"""
SELECT
  'sogo' AS system,
  CASE fi.c_folder_type WHEN 'Appointment' THEN 'calendar' WHEN 'Contact' THEN 'addressbook' ELSE fi.c_folder_type END AS type,
  SUBSTRING_INDEX(SUBSTRING_INDEX(fi.c_path, '/Users/', -1), '/', 1) AS owner,
  fi.c_path AS resource,
  fi.c_foldername AS name,
  a.c_uid AS grantee,
  a.c_role AS role_or_rights
FROM {acl} a
JOIN {fi} fi ON fi.c_folder_id = a.c_folder_id
{where_sql}
ORDER BY fi.c_folder_type, fi.c_path, a.c_uid, a.c_role;
""".strip()

    out = mysql_query(compose_file, DEFAULT_DB_CONTAINER, db_user, db_pass, db_name, sql)
    if not out:
        return []
    return [line.split("\t") for line in out.splitlines()]


def sogo_list_shares_aggregated(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    owner: Optional[str],
    typ: Optional[ShareType],
) -> List[List[str]]:
    raw = sogo_list_shares_raw(compose_file, db_user, db_pass, db_name, owner, typ)
    grouped: Dict[Tuple[str, str, str, str, str], Set[str]] = defaultdict(set)
    name_by_key: Dict[Tuple[str, str, str, str, str], str] = {}

    for system, type_str, owner_str, resource, name, grantee, role in raw:
        key = (system, type_str, owner_str, resource, grantee)
        grouped[key].add(role.strip())
        name_by_key[key] = name

    out_rows: List[List[str]] = []
    for key, roles in sorted(grouped.items(), key=lambda kv: kv[0]):
        system, type_str, owner_str, resource, grantee = key
        name = name_by_key.get(key, "")
        display = sogo_roles_to_display(type_str, roles)
        out_rows.append([system, type_str, owner_str, resource, name, grantee, display])

    return out_rows


# ----------------- SOGo type detection (for create/delete) -----------------
def sogo_detect_type_by_db(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    owner: str,
    path: str,
) -> Tuple[Optional[ShareType], Optional[str]]:
    p = (path or "").strip()

    candidates: List[str] = []
    if p.startswith("Calendar/") or p.startswith("Contacts/"):
        candidates.append(p)
    else:
        candidates.append(f"Calendar/{p}")
        candidates.append(f"Contacts/{p}")

    fi = f"{SOGO_TABLE_PREFIX}folder_info"
    for cand in candidates:
        full = f"/Users/{owner}/{cand}"
        sql = f"""
SELECT c_folder_type
FROM {fi}
WHERE c_path = '{full}'
LIMIT 1;
""".strip()
        out = mysql_query(compose_file, DEFAULT_DB_CONTAINER, db_user, db_pass, db_name, sql).strip()
        if out == "Appointment":
            return ShareType.calendar, cand
        if out == "Contact":
            return ShareType.addressbook, cand

    return None, None


def detect_share_type(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    owner: str,
    path: str,
    typ: Optional[ShareType],
) -> Tuple[ShareType, str]:
    if typ is not None:
        return typ, path.strip()

    p = path.strip()
    if p.startswith("Calendar/"):
        return ShareType.calendar, p
    if p.startswith("Contacts/"):
        return ShareType.addressbook, p

    guessed, normalized = sogo_detect_type_by_db(compose_file, db_user, db_pass, db_name, owner, p)
    if guessed and normalized:
        return guessed, normalized

    return ShareType.mailbox, p


# ----------------- Dovecot -----------------
def dovecot_mailbox_separator() -> str:
    return "/"


def dovecot_list_mailboxes_all(compose_file: str, dovecot_container: str, owner: str) -> List[str]:
    cp = dc_exec(compose_file, dovecot_container, ["doveadm", "mailbox", "list", "-u", owner, "*"])
    return [l.strip() for l in (cp.stdout or "").splitlines() if l.strip()]


def dovecot_root_mailboxes(all_mailboxes: List[str], separator: str) -> List[str]:
    return [mb for mb in all_mailboxes if separator not in mb]


def dovecot_acl_get(compose_file: str, dovecot_container: str, owner: str, mailbox: str) -> str:
    cp = dc_exec(compose_file, dovecot_container, ["doveadm", "acl", "get", "-u", owner, mailbox])
    return cp.stdout or ""


def normalize_acl_id(grantee_email: str) -> str:
    reject_user_equals(grantee_email)
    g = grantee_email.strip()
    specials = {"anyone", "authenticated", "anonymous", "owner"}
    if g.lower() in specials:
        return g.lower()
    return f"user={g}"


def display_acl_id(acl_id: str) -> str:
    s = acl_id.strip()
    if s.startswith("user="):
        return s[len("user=") :]
    return s


def list_all_mailcow_users(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
) -> List[str]:
    out = mysql_query(
        compose_file,
        DEFAULT_DB_CONTAINER,
        db_user,
        db_pass,
        db_name,
        "SELECT username FROM mailbox WHERE active=1 ORDER BY username;",
    )
    return [l.strip() for l in out.splitlines() if l.strip()] if out else []


def dovecot_list_shares_for_owner(
    compose_file: str,
    dovecot_container: str,
    owner: str,
    mailbox_filter: Optional[str],
    include_subfolders: bool,
) -> List[List[str]]:
    rows: List[List[str]] = []
    sep = dovecot_mailbox_separator()
    all_mboxes = dovecot_list_mailboxes_all(compose_file, dovecot_container, owner)
    mboxes = all_mboxes if include_subfolders else dovecot_root_mailboxes(all_mboxes, sep)

    if mailbox_filter:
        mboxes = [m for m in mboxes if mailbox_filter in m]

    specials = {"anyone", "authenticated", "anonymous", "owner"}

    def is_acl_id(tok: str) -> bool:
        t = tok.strip()
        if not t:
            return False
        if t.lower() in specials:
            return True
        if "=" in t:
            return True
        return False

    for mb in mboxes:
        out = dovecot_acl_get(compose_file, dovecot_container, owner, mb)
        for line in out.splitlines():
            s = line.strip()
            if not s:
                continue
            low = s.lower()
            if low.startswith("id ") or low == "id" or low.startswith("username"):
                continue

            parts = re.split(r"\s+", s)
            if len(parts) < 2:
                continue

            if is_acl_id(parts[0]):  # "<id> <rights...>"
                acl_id = parts[0]
                rights = " ".join(parts[1:]).strip()
            elif len(parts) >= 3 and is_acl_id(parts[1]):  # alternate format
                acl_id = parts[1]
                rights = " ".join(parts[2:]).strip()
            else:
                continue

            if rights == "-" or not rights:
                continue

            rows.append(["dovecot", "mailbox", owner, mb, "", display_acl_id(acl_id), rights])

    return rows


def dovecot_acl_delete(
    compose_file: str,
    dovecot_container: str,
    owner: str,
    mailbox: str,
    grantee_email: str,
) -> subprocess.CompletedProcess:
    acl_id = normalize_acl_id(grantee_email)
    return dc_exec(compose_file, dovecot_container, ["doveadm", "acl", "delete", "-u", owner, mailbox, acl_id])


def dovecot_set_acl(
    compose_file: str,
    dovecot_container: str,
    owner: str,
    mailbox: str,
    grantee_email: str,
    rights: List[str],
) -> subprocess.CompletedProcess:
    acl_id = normalize_acl_id(grantee_email)
    return dc_exec(compose_file, dovecot_container, ["doveadm", "acl", "set", "-u", owner, mailbox, acl_id] + rights)


# ----------------- Dovecot inheritance management via extra.conf -----------------
def extra_conf_path(project_dir: Path) -> Path:
    return (project_dir / DOVECOT_EXTRA_REL).resolve()


def inheritance_block() -> str:
    return (
        INH_BEGIN
        + "plugin {\n"
        + "  acl_defaults_from_inbox = yes\n"
        + "}\n"
        + INH_END
    )


def read_text_or_empty(p: Path) -> str:
    if not p.exists():
        return ""
    return p.read_text(encoding="utf-8", errors="replace")


def inheritance_enabled(project_dir: Path) -> bool:
    txt = read_text_or_empty(extra_conf_path(project_dir))
    return INH_BEGIN in txt and INH_END in txt


def set_inheritance(project_dir: Path, enabled: bool) -> Tuple[Path, bool]:
    p = extra_conf_path(project_dir)
    p.parent.mkdir(parents=True, exist_ok=True)

    current = read_text_or_empty(p)

    pattern = re.compile(rf"{re.escape(INH_BEGIN)}.*?{re.escape(INH_END)}", re.DOTALL)
    stripped = re.sub(pattern, "", current)

    if enabled:
        desired = stripped.rstrip() + ("\n\n" if stripped.strip() else "") + inheritance_block()
    else:
        desired = stripped

    desired = desired.rstrip() + ("\n" if desired.strip() else "")

    if desired == current:
        return p, False

    p.write_text(desired, encoding="utf-8")
    return p, True


# ----------------- Desired-state fixer: append (user@domain) to personal displaynames -----------------
EMAIL_SUFFIX_RE = re.compile(r"\s*\([^)]*@[^)]*\)\s*$")


def extract_owner_from_path(c_path: str) -> str:
    # /Users/user@domain/Calendar/personal
    m = re.search(r"/Users/([^/]+)/", c_path)
    return m.group(1) if m else ""


def desired_displayname(current: str, owner: str) -> str:
    cur = (current or "").strip()
    ow = (owner or "").strip()
    if not ow:
        return cur

    # Remove any existing "(...@...)" at the end (wrong or right), then append correct one.
    base = EMAIL_SUFFIX_RE.sub("", cur).rstrip()
    return f"{base} ({ow})"


def sogo_personal_rows(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    which: str,  # calendar|addressbook|both
) -> List[List[str]]:
    fi = f"{SOGO_TABLE_PREFIX}folder_info"

    where_parts = []
    if which in ("calendar", "both"):
        where_parts.append("(c_folder_type='Appointment' AND c_path LIKE '/Users/%/Calendar/personal')")
    if which in ("addressbook", "both"):
        where_parts.append("(c_folder_type='Contact' AND c_path LIKE '/Users/%/Contacts/personal')")

    where_sql = " OR ".join(where_parts) if where_parts else "1=0"

    sql = f"""
SELECT
  c_folder_id,
  c_folder_type,
  c_path,
  c_foldername
FROM {fi}
WHERE {where_sql}
ORDER BY c_folder_type, c_path;
""".strip()

    out = mysql_query(compose_file, DEFAULT_DB_CONTAINER, db_user, db_pass, db_name, sql)
    if not out:
        return []
    return [line.split("\t") for line in out.splitlines()]


def sogo_personal_displayname_ensure(
    compose_file: str,
    db_user: str,
    db_pass: str,
    db_name: str,
    which: str,
    apply: bool,
) -> List[List[str]]:
    """
    Desired state:
      c_foldername == "<existing localized name> (user@domain)"
    We *preserve* the existing localized base name, only enforce the email suffix.
    If a wrong email suffix exists, it is replaced.
    """
    rows = sogo_personal_rows(compose_file, db_user, db_pass, db_name, which)
    changes: List[Tuple[str, str]] = []  # (folder_id, new_name)
    preview_rows: List[List[str]] = []

    for folder_id, folder_type, c_path, c_name in rows:
        owner = extract_owner_from_path(c_path)
        new_name = desired_displayname(c_name, owner)
        if (c_name or "").strip() != new_name.strip():
            changes.append((folder_id, new_name))
            preview_rows.append([folder_id, folder_type, c_path, c_name, new_name])

    if not apply:
        return preview_rows

    # Apply one-by-one by ID (safe across MariaDB versions; no REGEXP_REPLACE dependency)
    fi = f"{SOGO_TABLE_PREFIX}folder_info"
    for folder_id, new_name in changes:
        safe_name = new_name.replace("\\", "\\\\").replace("'", "\\'")
        sql = f"UPDATE {fi} SET c_foldername='{safe_name}' WHERE c_folder_id={int(folder_id)};"
        mysql_query(compose_file, DEFAULT_DB_CONTAINER, db_user, db_pass, db_name, sql)

    return preview_rows


# ----------------- Commands -----------------
@app.command("shares-list")
def shares_list(
    typ: Optional[ShareType] = typer.Option(None, "--type", help="Optional: calendar|addressbook|mailbox"),
    owner: Optional[str] = typer.Option(None, "--owner", help="Optional: filter by owner mailbox (email)"),
    raw: bool = typer.Option(
        False,
        "--raw/--no-raw",
        help="Show raw SOGo roles (one row per atomic role) instead of aggregated aliases.",
    ),
    mailbox_filter: Optional[str] = typer.Option(None, "--mailbox-filter", help="(mailbox) filter mailbox by substring"),
    mailbox_subfolders: bool = typer.Option(
        False,
        "--mailbox-subfolders/--no-mailbox-subfolders",
        help="Mailbox listing: include subfolders (recursive). Default: only ROOT mailboxes (top-level).",
    ),
    project_dir: Optional[str] = typer.Option(None, "--project-dir", help="Mailcow project dir (auto-detected if omitted)"),
    db_user: Optional[str] = typer.Option(None, "--db-user", help="Override DB user (default from mailcow.conf)"),
    db_pass: Optional[str] = typer.Option(None, "--db-pass", help="Override DB password (default from mailcow.conf)"),
    db_name: Optional[str] = typer.Option(None, "--db-name", help="Override DB name (default from mailcow.conf)"),
):
    try:
        pdir = discover_project_dir(project_dir)
        compose_file = str((pdir / DEFAULT_COMPOSE_FILE).resolve())
        conf_path = (pdir / DEFAULT_MAILCOW_CONF).resolve()

        conf = read_mailcow_conf(conf_path)
        db_name_r = resolve_db_name(conf, db_name)
        db_user_r, db_pass_r = resolve_db_credentials(conf, db_user, db_pass)

        rows: List[List[str]] = []

        if typ in (None, ShareType.calendar, ShareType.addressbook):
            if raw:
                rows += sogo_list_shares_raw(compose_file, db_user_r, db_pass_r, db_name_r, owner, typ)
            else:
                rows += sogo_list_shares_aggregated(compose_file, db_user_r, db_pass_r, db_name_r, owner, typ)

        if typ in (None, ShareType.mailbox):
            owners = [owner] if owner else list_all_mailcow_users(compose_file, db_user_r, db_pass_r, db_name_r)
            for o in owners:
                try:
                    rows += dovecot_list_shares_for_owner(
                        compose_file,
                        DEFAULT_DOVECOT_CONTAINER,
                        o,
                        mailbox_filter,
                        mailbox_subfolders,
                    )
                except Exception as e:
                    rows.append(["dovecot", "mailbox", o, "<error>", "", "<error>", str(e)])

        if not rows:
            typer.echo("No shares found (matching your filters).")
            raise typer.Exit(0)

        print_table(rows, ["system", "type", "owner", "resource", "name", "grantee", "role/rights"])

    except RuntimeError as e:
        typer.echo(f"ERROR: {e}")
        typer.echo("Hint: If you want to query as root, use: --db-user root")
        raise typer.Exit(1)


@app.command("shares-create")
def shares_create(
    typ: Optional[ShareType] = typer.Option(None, "--type", help="Optional: calendar|addressbook|mailbox (auto-detect if omitted)"),
    owner: str = typer.Option(..., "--owner", help="Owner mailbox (email)"),
    path: str = typer.Option(
        ...,
        "--path",
        help="SOGo: 'Calendar/personal' or 'Contacts/personal'; Dovecot: mailbox e.g. 'INBOX/Support' or '*' / 'INBOX/*'",
    ),
    grantee: List[str] = typer.Option(..., "--grantee", help="Repeatable: --grantee a@d.tld --grantee b@d.tld (NO user=)"),
    role: str = typer.Option(
        ...,
        "--role",
        help="SOGo: alias read|write|write-full or roles list; Dovecot: read|readwrite|admin",
    ),
    subscribe: bool = typer.Option(
        True,
        "--subscribe/--no-subscribe",
        help="(SOGo) Automatically subscribe the shared calendar/addressbook for the grantee.",
    ),
    recursive: bool = typer.Option(
        False,
        "--recursive/--no-recursive",
        help="(mailbox) Apply ACL to mailbox and all subfolders (enumerates and sets each mailbox).",
    ),
    project_dir: Optional[str] = typer.Option(None, "--project-dir", help="Mailcow project dir (auto-detected if omitted)"),
    db_user: Optional[str] = typer.Option(None, "--db-user", help="Override DB user (default from mailcow.conf)"),
    db_pass: Optional[str] = typer.Option(None, "--db-pass", help="Override DB password (default from mailcow.conf)"),
    db_name: Optional[str] = typer.Option(None, "--db-name", help="Override DB name (default from mailcow.conf)"),
):
    for g in grantee:
        reject_user_equals(g)

    pdir = discover_project_dir(project_dir)
    compose_file = str((pdir / DEFAULT_COMPOSE_FILE).resolve())
    conf_path = (pdir / DEFAULT_MAILCOW_CONF).resolve()

    conf = read_mailcow_conf(conf_path)
    db_name_r = resolve_db_name(conf, db_name)
    db_user_r, db_pass_r = resolve_db_credentials(conf, db_user, db_pass)

    typ_r, path_r = detect_share_type(compose_file, db_user_r, db_pass_r, db_name_r, owner, path, typ)

    # ---- SOGo ----
    if typ_r in (ShareType.calendar, ShareType.addressbook):
        if recursive:
            typer.echo("ERROR: --recursive is only valid for mailbox.")
            raise typer.Exit(1)

        roles = sogo_parse_role_input(typ_r, role)
        if not roles:
            typer.echo("ERROR: SOGo role set is empty.")
            raise typer.Exit(1)

        for g in grantee:
            sogo_acl_add(compose_file, owner, path_r, g, roles)
            if subscribe:
                sogo_acl_subscribe(compose_file, owner, path_r, g, roles)
        return

    # ---- Dovecot mailbox ACL ----
    role_map = {
        "read": ["lookup", "read"],
        "readonly": ["lookup", "read"],
        "readwrite": ["lookup", "read", "write", "insert", "post", "create", "delete", "expunge"],
        "editor": ["lookup", "read", "write", "insert", "post", "create", "delete", "expunge"],
        "admin": ["lookup", "read", "write", "insert", "post", "create", "delete", "expunge", "admin"],
    }
    rights = role_map.get(role.lower())
    if not rights:
        typer.echo(f"Unknown dovecot role '{role}'. Known: {', '.join(sorted(role_map.keys()))}")
        raise typer.Exit(1)

    sep = dovecot_mailbox_separator()
    all_mboxes = dovecot_list_mailboxes_all(compose_file, DEFAULT_DOVECOT_CONTAINER, owner)

    wildcard = False
    base_path = path_r.strip()

    if base_path == "*":
        wildcard = True
        base_prefix = ""
    elif base_path.endswith(sep + "*"):
        wildcard = True
        base_prefix = base_path[:-2].rstrip(sep)
    else:
        base_prefix = base_path.rstrip(sep)

    def matches(mb: str) -> bool:
        if base_prefix == "":
            return True
        return mb == base_prefix or mb.startswith(base_prefix + sep)

    if wildcard or recursive:
        mailboxes_to_set = [mb for mb in all_mboxes if matches(mb)]
    else:
        mailboxes_to_set = [base_prefix]

    if not mailboxes_to_set:
        typer.echo(f"ERROR: No mailboxes found for owner {owner} matching path '{path_r}'")
        raise typer.Exit(1)

    for mb in mailboxes_to_set:
        for g in grantee:
            try:
                cp = dovecot_set_acl(compose_file, DEFAULT_DOVECOT_CONTAINER, owner, mb, g, rights)
                if cp.stdout and cp.stdout.strip():
                    typer.echo(cp.stdout.strip())
                if cp.stderr and cp.stderr.strip():
                    typer.echo(cp.stderr.strip())
            except subprocess.CalledProcessError as e:
                msg = (e.stderr or "").strip() or (e.stdout or "").strip() or "doveadm failed"
                typer.echo(f"ERROR: dovecot acl set failed for mailbox='{mb}', grantee='{g}': {msg}")
                raise typer.Exit(1)


@app.command("shares-delete")
def shares_delete(
    typ: Optional[ShareType] = typer.Option(None, "--type", help="Optional: calendar|addressbook|mailbox (auto-detect if omitted)"),
    owner: str = typer.Option(..., "--owner", help="Owner mailbox (email)"),
    path: str = typer.Option(
        ...,
        "--path",
        help="SOGo: 'Calendar/personal' or 'Contacts/personal'; Dovecot: mailbox e.g. 'INBOX/Support' or '*' / 'INBOX/*'",
    ),
    grantee: List[str] = typer.Option(..., "--grantee", help="Repeatable: --grantee a@d.tld --grantee b@d.tld (NO user=)"),
    role: Optional[str] = typer.Option(
        None,
        "--role",
        help="Optional: SOGo alias read|write|write-full or roles list. If omitted, removes share entry if supported. Not used for Dovecot.",
    ),
    unsubscribe: bool = typer.Option(
        True,
        "--unsubscribe/--no-unsubscribe",
        help="(SOGo) Also unsubscribe the grantee (remove from sidebar/listing). Default: unsubscribe.",
    ),
    recursive: bool = typer.Option(
        False,
        "--recursive/--no-recursive",
        help="(mailbox) Delete ACL from mailbox and all subfolders (enumerates and deletes each mailbox).",
    ),
    project_dir: Optional[str] = typer.Option(None, "--project-dir", help="Mailcow project dir (auto-detected if omitted)"),
    db_user: Optional[str] = typer.Option(None, "--db-user", help="Override DB user (default from mailcow.conf)"),
    db_pass: Optional[str] = typer.Option(None, "--db-pass", help="Override DB password (default from mailcow.conf)"),
    db_name: Optional[str] = typer.Option(None, "--db-name", help="Override DB name (default from mailcow.conf)"),
):
    for g in grantee:
        reject_user_equals(g)

    pdir = discover_project_dir(project_dir)
    compose_file = str((pdir / DEFAULT_COMPOSE_FILE).resolve())
    conf_path = (pdir / DEFAULT_MAILCOW_CONF).resolve()

    conf = read_mailcow_conf(conf_path)
    db_name_r = resolve_db_name(conf, db_name)
    db_user_r, db_pass_r = resolve_db_credentials(conf, db_user, db_pass)

    typ_r, path_r = detect_share_type(compose_file, db_user_r, db_pass_r, db_name_r, owner, path, typ)

    # ---- SOGo ----
    if typ_r in (ShareType.calendar, ShareType.addressbook):
        if recursive:
            typer.echo("ERROR: --recursive is only valid for mailbox.")
            raise typer.Exit(1)

        roles: Optional[Set[str]] = None
        if role:
            roles = sogo_parse_role_input(typ_r, role)

        # Desired behavior: unsubscribe too (default)
        for g in grantee:
            if unsubscribe:
                sogo_acl_unsubscribe(compose_file, owner, path_r, g)
            sogo_acl_remove(compose_file, owner, path_r, g, roles)

        return

    # ---- Dovecot mailbox ACL ----
    sep = dovecot_mailbox_separator()
    all_mboxes = dovecot_list_mailboxes_all(compose_file, DEFAULT_DOVECOT_CONTAINER, owner)

    wildcard = False
    base_path = path_r.strip()

    if base_path == "*":
        wildcard = True
        base_prefix = ""
    elif base_path.endswith(sep + "*"):
        wildcard = True
        base_prefix = base_path[:-2].rstrip(sep)
    else:
        base_prefix = base_path.rstrip(sep)

    def matches(mb: str) -> bool:
        if base_prefix == "":
            return True
        return mb == base_prefix or mb.startswith(base_prefix + sep)

    if wildcard or recursive:
        mailboxes_to_set = [mb for mb in all_mboxes if matches(mb)]
    else:
        mailboxes_to_set = [base_prefix]

    if not mailboxes_to_set:
        typer.echo(f"ERROR: No mailboxes found for owner {owner} matching path '{path_r}'")
        raise typer.Exit(1)

    for mb in mailboxes_to_set:
        for g in grantee:
            try:
                cp = dovecot_acl_delete(compose_file, DEFAULT_DOVECOT_CONTAINER, owner, mb, g)
                if cp.stdout and cp.stdout.strip():
                    typer.echo(cp.stdout.strip())
                if cp.stderr and cp.stderr.strip():
                    typer.echo(cp.stderr.strip())
            except subprocess.CalledProcessError as e:
                msg = (e.stderr or "").strip() or (e.stdout or "").strip() or "doveadm failed"
                typer.echo(f"ERROR: dovecot acl delete failed for mailbox='{mb}', grantee='{g}': {msg}")
                raise typer.Exit(1)


@app.command("dovecot-inheritance")
def dovecot_inheritance(
    action: Optional[str] = typer.Argument(None, help="Optional: status|change|toggle (default: status)"),
    state: Optional[str] = typer.Argument(None, help="For 'change': on|off"),
    project_dir: Optional[str] = typer.Option(None, "--project-dir", help="Mailcow project dir (auto-detected if omitted)"),
):
    pdir = discover_project_dir(project_dir)
    compose_file = str((pdir / DEFAULT_COMPOSE_FILE).resolve())

    act = (action or "status").strip().lower()

    def show() -> bool:
        enabled = inheritance_enabled(pdir)
        typer.echo(f"inheritance: {'on' if enabled else 'off'}")
        typer.echo(f"file: {extra_conf_path(pdir)}")
        return enabled

    if act == "status":
        show()
        raise typer.Exit(0)

    if act == "toggle":
        current = show()
        desired = not current
        p, changed = set_inheritance(pdir, desired)
        typer.echo(f"set: {'on' if desired else 'off'} ({'changed' if changed else 'no change'})")
        typer.echo(f"file: {p}")
        typer.echo("Restarting mailcow stack (docker compose down && up -d) ...")
        dc_down_up(compose_file)
        typer.echo("Done.")
        raise typer.Exit(0)

    if act == "change":
        if not state:
            typer.echo("ERROR: missing STATE for 'change' (use: change on|off)")
            raise typer.Exit(1)
        st = state.strip().lower()
        if st not in ("on", "off"):
            typer.echo("ERROR: STATE must be 'on' or 'off'")
            raise typer.Exit(1)
        desired = st == "on"
        p, changed = set_inheritance(pdir, desired)
        typer.echo(f"set: {'on' if desired else 'off'} ({'changed' if changed else 'no change'})")
        typer.echo(f"file: {p}")
        typer.echo("Restarting mailcow stack (docker compose down && up -d) ...")
        dc_down_up(compose_file)
        typer.echo("Done.")
        raise typer.Exit(0)

    typer.echo("ERROR: action must be one of: status | change | toggle")
    raise typer.Exit(1)


@app.command("sogo-personal-displayname")
def sogo_personal_displayname_cmd(
    action: str = typer.Argument(..., help="ensure"),
    which: str = typer.Option(
        "both",
        "--which",
        help="What to enforce: calendar|addressbook|both (default: both)",
    ),
    apply: bool = typer.Option(False, "--apply", help="Apply changes (default: dry-run)"),
    restart: bool = typer.Option(
        True,
        "--restart/--no-restart",
        help="Restart memcached + sogo after apply (default: restart)",
    ),
    project_dir: Optional[str] = typer.Option(None, "--project-dir", help="Mailcow project dir (auto-detected if omitted)"),
    db_user: Optional[str] = typer.Option(None, "--db-user", help="Override DB user (default from mailcow.conf)"),
    db_pass: Optional[str] = typer.Option(None, "--db-pass", help="Override DB password (default from mailcow.conf)"),
    db_name: Optional[str] = typer.Option(None, "--db-name", help="Override DB name (default from mailcow.conf)"),
):
    act = (action or "").strip().lower()
    which_n = (which or "").strip().lower()
    if act != "ensure":
        typer.echo("ERROR: action must be 'ensure'")
        raise typer.Exit(1)
    if which_n not in ("calendar", "addressbook", "both"):
        typer.echo("ERROR: --which must be calendar|addressbook|both")
        raise typer.Exit(1)

    pdir = discover_project_dir(project_dir)
    compose_file = str((pdir / DEFAULT_COMPOSE_FILE).resolve())
    conf_path = (pdir / DEFAULT_MAILCOW_CONF).resolve()

    conf = read_mailcow_conf(conf_path)
    db_name_r = resolve_db_name(conf, db_name)
    db_user_r, db_pass_r = resolve_db_credentials(conf, db_user, db_pass)

    preview = sogo_personal_displayname_ensure(
        compose_file=compose_file,
        db_user=db_user_r,
        db_pass=db_pass_r,
        db_name=db_name_r,
        which=which_n,
        apply=apply,
    )

    if not preview:
        typer.echo("Desired state already satisfied. No changes needed.")
        raise typer.Exit(0)

    print_table(preview, ["folder_id", "type", "path", "old_name", "new_name"])

    if not apply:
        typer.echo("\nDry-run only. Re-run with --apply to write changes.")
        raise typer.Exit(0)

    if restart:
        typer.echo("\nRestarting memcached-mailcow + sogo-mailcow ...")
        # most mailcow setups have memcached-mailcow; if yours differs, adjust here
        dc_restart_services(compose_file, ["memcached-mailcow", "sogo-mailcow"])
        typer.echo("Done.")


if __name__ == "__main__":
    app()
