#!/usr/bin/env python3
"""Central ACCOUNT config for the FIFA 17 offline stack (OpenFUT, clean-room).

ONE source of truth for the identity every layer has to agree on. Before this
module the same persona id / display name / namespace literals were copy-pasted
into blaze_responder_v3b.py, lsx_responder_v2.py, fut_store.py, fut_seed.py and
utas_server.py -- five files, seven copies. The stack only works while all of
them agree, so the copies were a silent drift surface.

    THE REAL CONSTRAINT IS CROSS-LAYER CONSISTENCY, NOT ANY PARTICULAR VALUE.
    Blaze LoginResponse.SESS.PDTL, LSX GetProfileResponse and the UTAS
    userInfo/squad bodies must all assert the SAME persona. That is why they
    now all read this module instead of their own literal.

    (The older comments in blaze/lsx claimed DSNM "MUST be CAGE" and PID "MUST
    be 33068179", justified by AUTH_ERR_INVALID_PERSONA. That justification is
    wrong on two counts: those are Blaze *server* error codes and we are the
    server, and neither literal appears anywhere in FIFA17.exe / CardsDLL /
    dbdata.dll. 33068179 occurs only inside stp-origin_emu.dll, as that emu's
    own ini default. The values are kept as DEFAULTS because they are what the
    currently-working stack asserts -- not because the client demands them.)

THREE TIERS
  1. LOCKED wire constants  -- module-level, no env, never persisted. These are
     baked into the binaries or into EA's own catalogue; changing them is a
     protocol change, not a preference.
  2. IDENTITY  -- persona id / display name / email / locale. Env-overridable,
     persisted.
  3. CLUB      -- club name / abbreviation / established year / squad name.
     Env-overridable, persisted. This tier is the offline, crash-free way to
     name your club (the in-game rename path is a separate, gated experiment).

PRECEDENCE for tiers 2 and 3:  env var  >  fut_account.json  >  built-in default.

PERSISTENCE
  Its own file, tools/fut_account.json (override with FUT_ACCOUNT_PATH), NOT the
  game save. Two reasons: the account must survive deleting fifa17_profile.json
  to reset progress, and blaze/lsx must be able to import this module without
  dragging in the profile store. On first load, if fut_account.json is absent
  and fifa17_profile.json exists, the identity/club values are MIGRATED out of
  it so an existing club name is never lost.

CLI (this is the safe club-rename path -- offline, no client involvement):
    python3 tools/fut_account.py --show
    python3 tools/fut_account.py --club-name 'Real OpenFUT' --club-abbr ROF
Then restart the harness. See --help.
"""
import json
import os
import sys
import threading

HERE = os.path.dirname(os.path.abspath(__file__))
ACCOUNT_PATH = os.environ.get("FUT_ACCOUNT_PATH", os.path.join(HERE, "fut_account.json"))
# Legacy home of these values; read once for migration, never written by us.
LEGACY_PROFILE_PATH = os.environ.get("FUT_PROFILE", os.path.join(HERE, "fifa17_profile.json"))

_LOCK = threading.RLock()

# ====================================================================== tier 1
# LOCKED WIRE CONSTANTS. No env override on purpose: these are not preferences.
# Each carries its provenance -- do not "clean up" a value without re-deriving it.

NAMESPACE = "cem_ea_id"
"""Persona namespace. Baked into FIFA17.exe (file offset 0x36b748) in the
BlazeSDK platform->namespace default table; exactly one occurrence. Must equal
PreAuthResponse.NASP and every later NASP/NSNM/ASRC we emit."""

PLATFORM = "pc"
"""Wire platform string. The client sends nucleusPersonaPlatform="pc" in its own
POST /ut/auth body -- we echo it, we do not choose it."""

CLIENT_PLATFORM = 4
"""Blaze::ClientPlatformType enum value for pc."""

SKU = "FFA17PCC"
"""CardsDLL FUN_180125900 literal @0x1802201e0; also the "game/<sku>" URL segment."""

TITLE_ID = "309111"
CLIENT_ID = "FIFA17-PC-SERVER-BLAZE"
CONTENT_ID = "1027460"
"""FIFA 17 EA offer id (retail)."""

ENTITLEMENT_TAG = "ONLINE_ACCESS"
"""TRIAL_ONLINE_ACCESS for FIFA17_Trial.exe."""

ENTITLEMENT_GROUP = "FIFA17PCBoxContent"
"""strstr needle @0x144334030. EntitlementComponent::onListEntitlements
(0x146f27440) keeps an entitlement only if GNAM contains "FIFA17PCBoxContent" or
"FIFA16PC", TAG is non-empty and STAT==1. Plain "FIFA17PC" matched neither and
produced an empty store."""

PERSONA_STATUS = 2
"""PersonaStatus::Code ACTIVE (verified live: table 0x14487ad20)."""

USER_SESSION_TYPE = 0
"""Blaze::UserSessionType -> normal/console user."""

_LOCKED = ("NAMESPACE", "PLATFORM", "CLIENT_PLATFORM", "SKU", "TITLE_ID",
           "CLIENT_ID", "CONTENT_ID", "ENTITLEMENT_TAG", "ENTITLEMENT_GROUP",
           "PERSONA_STATUS", "USER_SESSION_TYPE")

# ================================================================ tiers 2 + 3
# field -> (env var, default). Only these keys are ever persisted.
_FIELDS = {
    # tier 2: identity
    "persona_id":   ("FUT_PERSONA_ID",     33068179),
    "persona_name": ("FUT_PERSONA_NAME",   "CAGE"),
    "email":        ("FUT_ACCOUNT_EMAIL",  None),      # None -> derived from persona_name
    "locale":       ("FUT_LOCALE",         "en_US"),
    "country":      ("FUT_COUNTRY",        "US"),
    "currency":     ("FUT_CURRENCY",       "USD"),
    # tier 3: club
    "club_name":    ("FUT_CLUB_NAME",      "OpenFUT"),
    "club_abbr":    ("FUT_CLUB_ABBR",      "OFC"),
    "established":  ("FUT_ESTABLISHED",    "2026"),
    "squad_name":   ("FUT_SQUAD_NAME",     "OpenFUT"),
    # tier 4: the ONLINE (EASFC/POW) profile -- what the top-right hub bar shows.
    # Served by pow_server.py; key names below are the literal strings powdll's
    # parsers compare against (see pow_server.py for addresses), so these map 1:1
    # onto the wire:
    #   pow_level  -> "level",  pow_exp -> "exp"   (widget renders exp/expMax)
    #   pow_funds  -> EASFC credits, the coin counter next to the cart
    "pow_level":    ("FUT_POW_LEVEL",      1),
    "pow_exp":      ("FUT_POW_EXP",        0),
    "pow_exp_max":  ("FUT_POW_EXP_MAX",    1000),   # currLevelExpMax
    "pow_funds":    ("FUT_POW_FUNDS",      0),      # EASFC credit balance
    "pow_funds_cap": ("FUT_POW_FUNDS_CAP", 100000),
}
_INT_FIELDS = ("persona_id", "pow_level", "pow_exp", "pow_exp_max",
               "pow_funds", "pow_funds_cap")

# Club-name limits, reversed from CardsDLL:
#   * clubAbbr 1..3 -- the client's own write-back after a successful rename is
#     FUN_180007f80(rec+0x3e, 4, "%s", abbr), a FOUR-BYTE buffer (handler
#     FUN_1800829c0), so 4+ chars truncate.
#   * clubName 5..15 -- view-model builder FUN_180082c30 carries
#     name_min_length=5, name_max_length=0xf, abbr_max_length=3. The userInfo
#     write-back buffer at rec+0x20 is 30 bytes, so 15 is the binding constraint.
CLUB_NAME_MIN = 5
CLUB_NAME_MAX = 15
CLUB_ABBR_MIN = 1
CLUB_ABBR_MAX = 3

# CardsDLL FUN_180125900 uses this literal as the display name when the OSDK
# online-user object is NULL. Seeing it means "the client has no identity", not
# "the user is called mememe" -- never adopt it.
NULL_IDENTITY_NAME = "mememe"


def validate_club(name, abbr, established=None):
    """Validate club identity against the client's own limits.

    Returns (name, abbr) -- or (name, abbr, established) when `established` is
    passed. Raises ValueError with a message naming the reversed constraint.
    """
    if not isinstance(name, str):
        raise ValueError("clubName must be a string, got %r" % type(name).__name__)
    if not isinstance(abbr, str):
        raise ValueError("clubAbbr must be a string, got %r" % type(abbr).__name__)
    name = name.strip()
    abbr = abbr.strip()
    if not (CLUB_NAME_MIN <= len(name) <= CLUB_NAME_MAX):
        raise ValueError(
            "clubName %r is %d chars; must be %d..%d (view-model FUN_180082c30 "
            "name_min_length=5 name_max_length=0xf)"
            % (name, len(name), CLUB_NAME_MIN, CLUB_NAME_MAX))
    if not (CLUB_ABBR_MIN <= len(abbr) <= CLUB_ABBR_MAX):
        raise ValueError(
            "clubAbbr %r is %d chars; must be %d..%d (write-back buffer at "
            "userInfo+0x3e is 4 bytes -> FUN_180007f80(dst,4,\"%%s\",abbr))"
            % (abbr, len(abbr), CLUB_ABBR_MIN, CLUB_ABBR_MAX))
    if established is None:
        return name, abbr
    est = established
    if isinstance(est, int) and not isinstance(est, bool):
        est = str(est)
    # userInfo deser 0x18013ec10 case 0x110 takes the STRING getter then strtol
    # base 10 into rec+0x64. An int on the wire here is the scalar/string type
    # mismatch class that busy-loops the SAX reader at 0x1801c7f1a.
    if not isinstance(est, str) or not est.isdigit():
        raise ValueError("established must be a STRING of digits (deser "
                         "0x18013ec10 case 0x110 -> strtol base 10), got %r"
                         % (established,))
    return name, abbr, est


class Account:
    """Mutable singleton; see module docstring for the tier/precedence rules."""

    # tier 1 re-exported as attributes so call sites can just use ACCOUNT.X
    NAMESPACE = NAMESPACE
    PLATFORM = PLATFORM
    CLIENT_PLATFORM = CLIENT_PLATFORM
    SKU = SKU
    TITLE_ID = TITLE_ID
    CLIENT_ID = CLIENT_ID
    CONTENT_ID = CONTENT_ID
    ENTITLEMENT_TAG = ENTITLEMENT_TAG
    ENTITLEMENT_GROUP = ENTITLEMENT_GROUP
    PERSONA_STATUS = PERSONA_STATUS
    USER_SESSION_TYPE = USER_SESSION_TYPE

    def __init__(self, path=None):
        self.path = path or ACCOUNT_PATH
        self._loaded = False
        self._stored = {}          # what is on disk (tier 2+3 only)
        for f in _FIELDS:
            setattr(self, "_" + f, None)

    # ------------------------------------------------------------ persistence
    def load(self, force=False):
        """Idempotent. Reads fut_account.json, migrating from the legacy game
        save the first time. Never raises on a malformed file -- a broken
        account file must not stop the harness booting."""
        with _LOCK:
            if self._loaded and not force:
                return self
            stored = {}
            if os.path.exists(self.path):
                try:
                    with open(self.path) as f:
                        raw = json.load(f)
                    if isinstance(raw, dict):
                        stored = {k: v for k, v in raw.items() if k in _FIELDS}
                except (OSError, ValueError) as e:
                    sys.stderr.write("[account] WARN: ignoring unreadable %s (%s)\n"
                                     % (self.path, e))
            else:
                stored = self._migrate_from_profile()
                if stored:
                    self._stored = stored
                    try:
                        self._write()
                    except OSError as e:
                        # A read-only tools/ must not stop a server booting; the
                        # migrated values still apply for this process.
                        sys.stderr.write("[account] WARN: could not write %s (%s)\n"
                                         % (self.path, e))
            self._stored = stored
            self._loaded = True
            return self

    def _migrate_from_profile(self):
        """Lift identity/club out of a pre-existing fifa17_profile.json so an
        existing club name survives the move to this module. Read-only: the game
        save is never modified, and its copies stay there harmlessly."""
        if not os.path.exists(LEGACY_PROFILE_PATH):
            return {}
        try:
            with open(LEGACY_PROFILE_PATH) as f:
                p = json.load(f)
        except (OSError, ValueError):
            return {}
        if not isinstance(p, dict):
            return {}
        out = {}
        for src, dst in (("personaId", "persona_id"), ("personaName", "persona_name"),
                         ("clubName", "club_name"), ("clubAbbr", "club_abbr"),
                         ("established", "established")):
            if p.get(src) not in (None, ""):
                out[dst] = p[src]
        if out:
            sys.stderr.write("[account] migrated %s from %s\n"
                             % (",".join(sorted(out)), os.path.basename(LEGACY_PROFILE_PATH)))
        return out

    def _write(self):
        tmp = self.path + ".tmp"
        with open(tmp, "w") as f:
            json.dump(self._stored, f, indent=1, sort_keys=True)
            f.write("\n")
        os.replace(tmp, self.path)

    def save(self):
        """Persist tiers 2+3 (only fields that differ from the built-in default,
        plus anything already stored). Tier 1 is never written."""
        with _LOCK:
            self.load()
            for f in _FIELDS:
                v = getattr(self, "_" + f)
                if v is not None:
                    self._stored[f] = v
            self._write()
            return self

    # ------------------------------------------------------------ field access
    def _get(self, field):
        self.load()
        env, default = _FIELDS[field]
        v = getattr(self, "_" + field)
        if v is None:
            v = os.environ.get(env)
        if v is None:
            v = self._stored.get(field)
        if v is None:
            v = default
        if field in _INT_FIELDS and v is not None:
            v = int(v)
        return v

    def _set(self, field, value):
        with _LOCK:
            self.load()
            if field in _INT_FIELDS:
                value = int(value)
            setattr(self, "_" + field, value)

    # tier 2 ---------------------------------------------------------------
    @property
    def persona_id(self):
        """Blaze SESS.BUID / SESS.UID / PDTL.PID, LSX PersonaId/UserId, UTAS
        userInfo.personaId and squad.personaId. UNVERIFIED KNOB: REPACK_INTEL
        Section 2.2 records the repack's decrypted .dlf license carrying
        <UserId>33068179</UserId> (consumed by dbdata.dll!getTableData). No .dlf
        exists on disk any more, so changing this cannot be re-checked
        statically -- treat it as a deliberate single-variable experiment."""
        return self._get("persona_id")

    @persona_id.setter
    def persona_id(self, v):
        self._set("persona_id", v)

    @property
    def persona_name(self):
        """Blaze PDTL.DSNM / LSX GetProfileResponse Persona / UTAS sellerName."""
        return self._get("persona_name")

    @persona_name.setter
    def persona_name(self, v):
        v = str(v).strip()
        if not v:
            raise ValueError("persona_name must not be empty")
        self._set("persona_name", v)

    @property
    def email(self):
        """Blaze SESS.MAIL / AccountInfo.MAIL. Derived from persona_name when unset."""
        v = self._get("email")
        return v if v else "%s@openfut.local" % self.persona_name.lower()

    @email.setter
    def email(self, v):
        self._set("email", v)

    @property
    def locale(self):
        return self._get("locale")

    @locale.setter
    def locale(self, v):
        self._set("locale", v)

    @property
    def country(self):
        return self._get("country")

    @property
    def currency(self):
        return self._get("currency")

    # DERIVED, read-only. Deliberately NOT independent knobs: the client sends
    # both `nuc` and `nucleusPersonaId` and both came out equal, so the
    # getter->field mapping is undetermined. Do not split them until a live test
    # proves Blaze USER_ID/EXT_ID may legitimately differ from PERSONA_ID.
    @property
    def user_id(self):
        """Blaze blazeId / userId (SESS.BUID, SESS.UID, AccountInfo.UID)."""
        return self.persona_id

    @property
    def ext_id(self):
        """Blaze XREF / EXID externalId."""
        return self.persona_id

    @property
    def locale_dash(self):
        """"en-US" form, as the client sends it in POST /ut/auth."""
        return self.locale.replace("_", "-")

    @property
    def account_locale_int(self):
        """Packed 4-char locale for Blaze AccountInfo; 'enUS' == 0x656E5553.
        Overwritten per-session by the client's own PreAuthRequest LANG/LOC."""
        s = (self.locale.replace("_", "") + "\0\0\0\0")[:4]
        return int.from_bytes(s.encode("latin-1"), "big")

    # tier 3 ---------------------------------------------------------------
    @property
    def club_name(self):
        return self._get("club_name")

    @club_name.setter
    def club_name(self, v):
        name, _ = validate_club(v, self.club_abbr)
        self._set("club_name", name)

    @property
    def club_abbr(self):
        return self._get("club_abbr")

    @club_abbr.setter
    def club_abbr(self, v):
        _, abbr = validate_club(self.club_name, v)
        self._set("club_abbr", abbr)

    @property
    def established(self):
        """STRING of digits -- see validate_club()."""
        return str(self._get("established"))

    @established.setter
    def established(self, v):
        _, _, est = validate_club(self.club_name, self.club_abbr, v)
        self._set("established", est)

    @property
    def squad_name(self):
        return self._get("squad_name")

    @squad_name.setter
    def squad_name(self, v):
        self._set("squad_name", v)

    def set_club(self, name=None, abbr=None, established=None):
        """Atomic validated club update. Raises ValueError before mutating
        anything, so a rejected rename leaves the account untouched."""
        with _LOCK:
            n = self.club_name if name is None else name
            a = self.club_abbr if abbr is None else abbr
            e = self.established if established is None else established
            n, a, e = validate_club(n, a, e)
            self._set("club_name", n)
            self._set("club_abbr", a)
            self._set("established", e)
            return n, a, e

    # ------------------------------------------------- tier 4: online profile
    @property
    def pow_level(self):
        return self._get("pow_level")

    @property
    def pow_exp(self):
        return self._get("pow_exp")

    @property
    def pow_exp_max(self):
        return self._get("pow_exp_max")

    @property
    def pow_funds(self):
        return self._get("pow_funds")

    @property
    def pow_funds_cap(self):
        return self._get("pow_funds_cap")

    def set_online_profile(self, level=None, exp=None, exp_max=None,
                           funds=None, funds_cap=None):
        """Atomic validated update of the EASFC/POW profile (the top-right hub
        bar: LVL x, the exp bar, and the credit counter).

        Validation is deliberately light -- unlike the club fields there is no
        reversed length/range check to cite, so we only enforce what is
        structurally required: non-negative ints, and exp <= exp_max so the
        widget cannot render a bar past 100%."""
        with _LOCK:
            lv = self.pow_level if level is None else int(level)
            xp = self.pow_exp if exp is None else int(exp)
            xm = self.pow_exp_max if exp_max is None else int(exp_max)
            fu = self.pow_funds if funds is None else int(funds)
            fc = self.pow_funds_cap if funds_cap is None else int(funds_cap)
            if min(lv, xp, xm, fu, fc) < 0:
                raise ValueError("online-profile values must be >= 0")
            if lv < 1:
                raise ValueError("pow_level must be >= 1")
            if xm < 1:
                raise ValueError("pow_exp_max must be >= 1")
            if xp > xm:
                raise ValueError("pow_exp (%d) exceeds pow_exp_max (%d)" % (xp, xm))
            if fu > fc:
                raise ValueError("pow_funds (%d) exceeds pow_funds_cap (%d)" % (fu, fc))
            for k, v in (("pow_level", lv), ("pow_exp", xp), ("pow_exp_max", xm),
                         ("pow_funds", fu), ("pow_funds_cap", fc)):
                self._set(k, v)
            return lv, xp, xm, fu, fc

    # ------------------------------------------------------------- adoption
    def adopt_from_auth(self, body):
        """Adopt the identity the client itself asserts in POST /ut/auth.

        Live-observed body (three byte-identical runs; builder CardsDLL
        FUN_180125900):
          {"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","nuc":33068179,
           "nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE",
           "locale":"en-US","regionCode":"US",...}

        RECONCILIATION RULE: the wire is truth, the stored JSON is a cache.
        Adopt-and-overwrite with a WARN; never refuse -- a mismatch is the
        NORMAL state on the first boot after a rename. Returns True if anything
        changed. FUT_ADOPT_AUTH=0 disables adoption entirely.

        Why it matters: the squad parser 0x18013d1f0 stores personaId (atom
        0x21b) at squad+0x38 and compares it against
        FUN_18011a830()->vtbl[0x908]; on mismatch it silently builds a throwaway
        squad instead of erroring. Same comparison in FUN_1801464e0 for squad
        summaries. Adopting makes that comparison correct by construction.
        """
        if os.environ.get("FUT_ADOPT_AUTH") == "0":
            return False
        if not isinstance(body, dict):
            return False
        changed = []
        pid = body.get("nucleusPersonaId", body.get("nuc"))
        if isinstance(pid, (int, str)) and not isinstance(pid, bool):
            try:
                pid = int(pid)
            except (TypeError, ValueError):
                pid = None
            if pid and pid != self.persona_id:
                changed.append("persona %d -> %d" % (self.persona_id, pid))
                self._set("persona_id", pid)
        name = body.get("nucleusPersonaDisplayName")
        if isinstance(name, str):
            name = name.strip()
            if name and name != NULL_IDENTITY_NAME and name != self.persona_name:
                changed.append("name %r -> %r" % (self.persona_name, name))
                self._set("persona_name", name)
        loc = body.get("locale")
        if isinstance(loc, str) and loc:
            loc = loc.replace("-", "_")
            if loc != self.locale:
                changed.append("locale %r -> %r" % (self.locale, loc))
                self._set("locale", loc)
        if changed:
            sys.stderr.write("[account] WARN: adopted from /ut/auth: %s\n"
                             % "; ".join(changed))
            self.save()
        return bool(changed)

    # ------------------------------------------------------------------ misc
    def as_dict(self):
        """Effective tier 2+3 values plus the derived ones (for --show / logs)."""
        d = {f: getattr(self, f) for f in _FIELDS}
        d["email"] = self.email          # resolve the derived default
        d.update(user_id=self.user_id, ext_id=self.ext_id,
                 locale_dash=self.locale_dash,
                 account_locale_int=self.account_locale_int)
        return d

    def locked(self):
        return {k: globals()[k] for k in _LOCKED}

    def __repr__(self):
        return ("<Account persona=%d/%r club=%r/%r est=%s ns=%s>"
                % (self.persona_id, self.persona_name, self.club_name,
                   self.club_abbr, self.established, self.NAMESPACE))


ACCOUNT = Account()

# Back-compat aliases so the old module-level names keep resolving where they
# are still imported. Prefer ACCOUNT.<field> in new code -- these are snapshots
# taken at import time and will NOT reflect a later adopt_from_auth().
PERSONA_ID = ACCOUNT.persona_id
PERSONA_NAME = ACCOUNT.persona_name


# ===================================================================== CLI
def _main(argv):
    import argparse
    ap = argparse.ArgumentParser(
        prog="fut_account.py",
        description="Inspect / edit the OpenFUT FIFA 17 account identity. "
                    "Editing the club here is the SAFE rename path: it is "
                    "offline, validated against the client's own limits, and "
                    "never involves the in-game rename flow. Restart the "
                    "harness after changing anything.")
    ap.add_argument("--show", action="store_true", help="print the account and exit")
    ap.add_argument("--club-name", help="club name (%d..%d chars)" % (CLUB_NAME_MIN, CLUB_NAME_MAX))
    ap.add_argument("--club-abbr", help="club abbreviation (%d..%d chars)" % (CLUB_ABBR_MIN, CLUB_ABBR_MAX))
    ap.add_argument("--established", help="founding year, digits only")
    ap.add_argument("--squad-name", help="default squad name")
    ap.add_argument("--persona-name", help="display name (Blaze DSNM / LSX Persona)")
    ap.add_argument("--persona-id", type=int, help="persona id (UNVERIFIED knob; see docstring)")
    ap.add_argument("--email", help="account email (Blaze MAIL)")
    # tier 4: the online (EASFC/POW) profile shown in the top-right hub bar
    ap.add_argument("--pow-level", type=int, help="online profile level (LVL)")
    ap.add_argument("--pow-exp", type=int, help="online profile XP into the current level")
    ap.add_argument("--pow-exp-max", type=int, help="XP needed for the next level")
    ap.add_argument("--pow-funds", type=int, help="EASFC credits (the coin counter)")
    ap.add_argument("--pow-funds-cap", type=int, help="EASFC credit cap")
    ap.add_argument("--json", action="store_true", help="machine-readable output")
    a = ap.parse_args(argv)

    ACCOUNT.load()
    dirty = False
    try:
        if a.club_name or a.club_abbr or a.established:
            ACCOUNT.set_club(a.club_name, a.club_abbr, a.established)
            dirty = True
        if a.persona_name:
            ACCOUNT.persona_name = a.persona_name
            dirty = True
        if a.persona_id:
            ACCOUNT.persona_id = a.persona_id
            dirty = True
        if a.email:
            ACCOUNT.email = a.email
            dirty = True
        if a.squad_name:
            ACCOUNT.squad_name = a.squad_name
            dirty = True
        if any(v is not None for v in (a.pow_level, a.pow_exp, a.pow_exp_max,
                                       a.pow_funds, a.pow_funds_cap)):
            ACCOUNT.set_online_profile(a.pow_level, a.pow_exp, a.pow_exp_max,
                                       a.pow_funds, a.pow_funds_cap)
            dirty = True
    except ValueError as e:
        sys.stderr.write("error: %s\n" % e)
        return 2
    if dirty:
        ACCOUNT.save()
        print("saved %s" % ACCOUNT.path)

    if a.json:
        print(json.dumps({"account": ACCOUNT.as_dict(), "locked": ACCOUNT.locked()},
                         indent=1, sort_keys=True))
    else:
        d = ACCOUNT.as_dict()
        print("account file : %s" % ACCOUNT.path)
        print("-- identity (env-overridable, persisted) --")
        for k in ("persona_id", "persona_name", "email", "locale", "country", "currency"):
            print("  %-14s %s" % (k, d[k]))
        print("-- club --")
        for k in ("club_name", "club_abbr", "established", "squad_name"):
            print("  %-14s %s" % (k, d[k]))
        print("-- derived --")
        for k in ("user_id", "ext_id", "locale_dash"):
            print("  %-14s %s" % (k, d[k]))
        print("  %-14s 0x%08x" % ("account_locale", d["account_locale_int"]))
        print("-- locked wire constants (not settable) --")
        for k, v in sorted(ACCOUNT.locked().items()):
            print("  %-18s %s" % (k, v))
    if dirty:
        print("\nrestart the harness for this to take effect: ./openfut-fut.sh restart")
    return 0


if __name__ == "__main__":
    sys.exit(_main(sys.argv[1:]))
