#!/usr/bin/env python3
"""Securely configure WeChat credentials without exposing the secret."""

from __future__ import annotations

import argparse
import getpass
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path


APP_ID_RE = re.compile(r"^wx[A-Za-z0-9]{16}$")
APP_SECRET_RE = re.compile(r"^[A-Za-z0-9]{32}$")
ACCOUNT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,47}$")
MANAGED_KEYS = ("WECHAT_APP_ID", "WECHAT_APP_SECRET")


def account_env_path(account_id: str | None = None) -> Path:
    if account_id:
        if not ACCOUNT_ID_RE.fullmatch(account_id):
            raise ValueError("Account id must contain only lowercase letters, digits, and hyphens.")
        return Path.home() / ".wechat" / "accounts" / account_id / "credentials.env"
    return Path.home() / ".wechat" / ".env"


def legacy_config_path() -> Path:
    return Path.home() / ".wechat" / "config.json"


def parse_dotenv(text: str) -> dict[str, str]:
    values: dict[str, str] = {}
    for raw_line in text.splitlines():
        line = raw_line.strip()
        if not line or line.startswith("#"):
            continue
        if line.startswith("export "):
            line = line[7:].lstrip()
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        key = key.strip()
        value = value.strip()
        if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
            value = value[1:-1]
        values[key] = value
    return values


def read_env(path: Path) -> tuple[str, dict[str, str]]:
    if not path.exists():
        return "", {}
    text = path.read_text(encoding="utf-8-sig")
    return text, parse_dotenv(text)


def credentials_ready(values: dict[str, str]) -> bool:
    return bool(APP_ID_RE.fullmatch(values.get("WECHAT_APP_ID", ""))) and bool(
        APP_SECRET_RE.fullmatch(values.get("WECHAT_APP_SECRET", ""))
    )


def mask_app_id(app_id: str) -> str:
    if len(app_id) < 8:
        return "configured"
    return f"{app_id[:4]}{'*' * (len(app_id) - 8)}{app_id[-4:]}"


def show_status(path: Path) -> int:
    try:
        _, values = read_env(path)
    except (OSError, UnicodeError):
        print(f"Credential file: {path}")
        print("Status: unreadable")
        return 2

    app_id = values.get("WECHAT_APP_ID", "")
    secret = values.get("WECHAT_APP_SECRET", "")
    print(f"Credential file: {path}")
    print(f"AppID: {mask_app_id(app_id) if APP_ID_RE.fullmatch(app_id) else 'missing or invalid'}")
    print(f"AppSecret: {'configured' if APP_SECRET_RE.fullmatch(secret) else 'missing or invalid'}")
    ready = credentials_ready(values)
    print(f"Status: {'ready' if ready else 'not ready'}")
    if not ready and legacy_config_path().exists():
        print(f"Legacy config detected: {legacy_config_path()}")
    return 0 if ready else 2


def merge_credentials(existing: str, app_id: str, app_secret: str) -> str:
    replacements = {
        "WECHAT_APP_ID": app_id,
        "WECHAT_APP_SECRET": app_secret,
    }
    seen: set[str] = set()
    output: list[str] = []

    for line in existing.splitlines():
        match = re.match(r"^\s*(?:export\s+)?(WECHAT_APP_ID|WECHAT_APP_SECRET)\s*=", line)
        if not match:
            output.append(line)
            continue
        key = match.group(1)
        if key not in seen:
            output.append(f"{key}={replacements[key]}")
            seen.add(key)

    if output and output[-1].strip():
        output.append("")
    for key in MANAGED_KEYS:
        if key not in seen:
            output.append(f"{key}={replacements[key]}")
    return "\n".join(output).rstrip() + "\n"


def harden_permissions(path: Path) -> bool:
    try:
        os.chmod(path, 0o600)
    except OSError:
        return False

    if os.name != "nt":
        return True

    try:
        identity = subprocess.run(
            ["whoami"],
            check=True,
            capture_output=True,
            text=True,
        ).stdout.strip()
        if not identity:
            return False
        result = subprocess.run(
            ["icacls", str(path), "/inheritance:r", "/grant:r", f"{identity}:F"],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=False,
        )
        return result.returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def write_credentials(path: Path, app_id: str, app_secret: str) -> bool:
    existing, _ = read_env(path)
    content = merge_credentials(existing, app_id, app_secret)
    path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)

    temp_name: str | None = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            newline="\n",
            dir=path.parent,
            prefix=".env.",
            suffix=".tmp",
            delete=False,
        ) as temp_file:
            temp_name = temp_file.name
            temp_file.write(content)
        os.chmod(temp_name, 0o600)
        os.replace(temp_name, path)
        temp_name = None
    finally:
        if temp_name:
            try:
                Path(temp_name).unlink(missing_ok=True)
            except OSError:
                pass

    return harden_permissions(path)


def prompt_app_id() -> str:
    while True:
        app_id = input("AppID (starts with wx): ").strip()
        if APP_ID_RE.fullmatch(app_id):
            return app_id
        print("Invalid AppID. Expected wx followed by 16 letters or digits.")


def prompt_app_secret() -> str:
    while True:
        first = getpass.getpass("AppSecret (hidden): ").strip()
        if not APP_SECRET_RE.fullmatch(first):
            print("Invalid AppSecret. Expected 32 letters or digits.")
            continue
        second = getpass.getpass("Repeat AppSecret (hidden): ").strip()
        if first != second:
            print("AppSecret values do not match. Try again.")
            continue
        return first


def load_legacy_credentials(path: Path) -> tuple[str, str]:
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except (OSError, UnicodeError, json.JSONDecodeError) as exc:
        raise ValueError("Legacy config could not be read.") from exc

    app_id = data.get("app_id", "")
    app_secret = data.get("app_secret", "")
    if not isinstance(app_id, str) or not APP_ID_RE.fullmatch(app_id):
        raise ValueError("Legacy config contains a missing or invalid AppID.")
    if not isinstance(app_secret, str) or not APP_SECRET_RE.fullmatch(app_secret):
        raise ValueError("Legacy config contains a missing or invalid AppSecret.")
    return app_id, app_secret


def require_interactive_terminal() -> None:
    if not sys.stdin.isatty():
        raise RuntimeError(
            "Secure input requires an interactive local terminal. "
            "Run this script there; do not paste AppSecret into chat."
        )


def confirm_save(path: Path, app_id: str, source: str) -> bool:
    print("")
    print(f"Source: {source}")
    print(f"AppID: {mask_app_id(app_id)}")
    print("AppSecret: entered and hidden")
    print(f"Save to: {path}")
    return input("Confirm save? [y/N]: ").strip().lower() in {"y", "yes"}


def configure(path: Path, migrate_legacy: bool, force: bool, account_id: str | None) -> int:
    _, current = read_env(path)
    if credentials_ready(current) and not force:
        print("Credentials are already configured. Use --force to replace them.")
        return show_status(path)

    require_interactive_terminal()
    if migrate_legacy:
        app_id, app_secret = load_legacy_credentials(legacy_config_path())
        source = str(legacy_config_path())
    else:
        print("Enter credentials in this local terminal. AppSecret input is hidden.")
        app_id = prompt_app_id()
        app_secret = prompt_app_secret()
        source = "interactive terminal"

    if not confirm_save(path, app_id, source):
        print("Cancelled. No credentials were changed.")
        return 1

    permissions_hardened = write_credentials(path, app_id, app_secret)
    app_secret = ""
    print(f"Credentials saved to {path}")
    if not permissions_hardened:
        print("Warning: credentials were saved, but file permissions could not be tightened.")
    return show_status(path)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Securely save WeChat credentials to a local account file."
    )
    parser.add_argument(
        "--account",
        help="Local account id, for example tiny-wings or sanhe-xu. Omit for the legacy default ~/.wechat/.env.",
    )
    parser.add_argument("--status", action="store_true", help="Check status without reading values aloud.")
    parser.add_argument(
        "--migrate-legacy",
        action="store_true",
        help="Migrate ~/.wechat/config.json through an interactive confirmation.",
    )
    parser.add_argument("--force", action="store_true", help="Replace an existing complete configuration.")
    return parser


def main() -> int:
    args = build_parser().parse_args()
    try:
        path = account_env_path(args.account)
        if args.status:
            return show_status(path)
        return configure(path, args.migrate_legacy, args.force, args.account)
    except (EOFError, KeyboardInterrupt):
        print(
            "Error: interactive input is unavailable. Run this command in your local terminal; "
            "do not paste AppSecret into chat.",
            file=sys.stderr,
        )
        return 2
    except (OSError, RuntimeError, ValueError) as exc:
        print(f"Error: {exc}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
